@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/dist/index.js CHANGED
@@ -50,111 +50,6 @@ var logTransport = (batch) => {
50
50
  }
51
51
  };
52
52
 
53
- // src/live.ts
54
- var LiveClient = class {
55
- constructor(cfg) {
56
- this.cfg = cfg;
57
- }
58
- cfg;
59
- _ws = null;
60
- _id = 0;
61
- _backoffMs = 1e3;
62
- _disposed = false;
63
- _connectAcked = false;
64
- /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */
65
- async connect() {
66
- if (this._ws) return;
67
- if (this._disposed) return;
68
- this.cfg.onStatus?.("connecting");
69
- const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;
70
- let url = this.cfg.url;
71
- if (!url.includes("/connection/websocket")) {
72
- url = url.replace(/\/+$/, "") + "/connection/websocket";
73
- }
74
- const ws = new WebSocket(url);
75
- this._ws = ws;
76
- this._connectAcked = false;
77
- ws.onopen = () => {
78
- this._send({ id: ++this._id, connect: { token } });
79
- };
80
- ws.onmessage = (evt) => this._onFrame(evt.data);
81
- ws.onclose = () => this._onClose();
82
- ws.onerror = () => {
83
- };
84
- }
85
- dispose() {
86
- this._disposed = true;
87
- if (this._ws) {
88
- try {
89
- this._ws.close();
90
- } catch {
91
- }
92
- this._ws = null;
93
- }
94
- this.cfg.onStatus?.("disconnected");
95
- }
96
- // ---- internals ----
97
- _send(msg) {
98
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
99
- try {
100
- this._ws.send(JSON.stringify(msg));
101
- } catch {
102
- }
103
- }
104
- _onFrame(raw) {
105
- let parsed;
106
- try {
107
- parsed = JSON.parse(raw);
108
- } catch {
109
- return;
110
- }
111
- if (!parsed || typeof parsed !== "object") return;
112
- if (Object.keys(parsed).length === 0) {
113
- this._send({});
114
- return;
115
- }
116
- if (parsed.connect && !this._connectAcked) {
117
- this._connectAcked = true;
118
- this.cfg.onStatus?.("connected");
119
- this._backoffMs = 1e3;
120
- this._send({
121
- id: ++this._id,
122
- subscribe: { channel: this.cfg.channel }
123
- });
124
- return;
125
- }
126
- const push = parsed.push;
127
- if (push && push.channel === this.cfg.channel && push.pub) {
128
- this.cfg.onMessage(push.pub.data);
129
- }
130
- }
131
- _onClose() {
132
- this._ws = null;
133
- this._connectAcked = false;
134
- this.cfg.onStatus?.("disconnected");
135
- if (this._disposed) return;
136
- const delay = this._backoffMs;
137
- this._backoffMs = Math.min(this._backoffMs * 2, 3e4);
138
- setTimeout(() => {
139
- if (!this._disposed) void this.connect();
140
- }, delay);
141
- }
142
- };
143
- async function fetchCentrifugoToken(endpoint, projectUuid, authToken, fetchImpl = fetch) {
144
- const r = await fetchImpl(endpoint, {
145
- method: "POST",
146
- headers: {
147
- "Content-Type": "application/json",
148
- Authorization: `ApiKey ${authToken}`
149
- },
150
- body: JSON.stringify({ project_uuid: projectUuid })
151
- });
152
- if (!r.ok) {
153
- throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);
154
- }
155
- return await r.json();
156
- }
157
-
158
53
  // src/key-registry.ts
159
54
  var GLOBAL = "__verbumia_key_registry__";
160
55
  var SEP = "\0";
@@ -292,10 +187,10 @@ var VerbumiaI18n = class {
292
187
  fallbackLng;
293
188
  missingEvents = [];
294
189
  _bundles = /* @__PURE__ */ new Map();
295
- // `${locale}/${ns}` -> tree
190
+ // `${version}/${locale}/${ns}` -> tree
296
191
  _attempted = /* @__PURE__ */ new Set();
297
- // `${locale}/${ns}` keys we've fetched
298
- // Tighter gate than `_attempted`: this set only contains (locale, ns)
192
+ // `${version}/${locale}/${ns}` keys we've fetched
193
+ // Tighter gate than `_attempted`: this set only contains (version, locale, ns)
299
194
  // pairs whose CDN response was 200 with at least one top-level key. An
300
195
  // empty bundle (404 → {} OR 200 → {}) is treated as "no data yet";
301
196
  // calling t() against a key in such a bundle does NOT fire reportMissing.
@@ -310,13 +205,20 @@ var VerbumiaI18n = class {
310
205
  // dedup `${locale}/${ns}/${key}` per-flush
311
206
  _timer = null;
312
207
  _listeners = /* @__PURE__ */ new Set();
313
- _live = null;
314
208
  // Stable snapshot reference for useSyncExternalStore. Returning a fresh
315
209
  // object on each getSnapshot call would loop React forever — we rebuild
316
210
  // it ONLY in _notify (when state actually changed) and return the cached
317
211
  // reference between notifications.
318
212
  _snapshot;
319
213
  constructor(config) {
214
+ const removedRealtimeKeys = Object.keys(config).filter(
215
+ (k) => k === "liveUpdates" || k.startsWith("centrifugo")
216
+ );
217
+ if (removedRealtimeKeys.length > 0) {
218
+ throw new Error(
219
+ `@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.`
220
+ );
221
+ }
320
222
  this.locale = config.defaultLocale;
321
223
  this.fallbackLng = config.fallbackLng;
322
224
  this._config = {
@@ -325,14 +227,11 @@ var VerbumiaI18n = class {
325
227
  missingHandler: config.missingHandler ?? "send",
326
228
  token: config.token,
327
229
  projectUuid: config.projectUuid,
328
- namespaces: config.namespaces?.length ? config.namespaces : ["common"],
230
+ namespaces: config.namespaces?.length ? config.namespaces : config.defaultNS ? [config.defaultNS] : ["common"],
329
231
  flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_MS,
330
232
  flushBatchSize: config.flushBatchSize ?? DEFAULT_BATCH,
331
233
  missingEventsBufferSize: config.missingEventsBufferSize ?? DEFAULT_BUFFER,
332
- versionSlug: config.versionSlug ?? DEFAULT_VERSION_SLUG,
333
- liveUpdates: !!config.liveUpdates,
334
- centrifugoTokenEndpoint: config.centrifugoTokenEndpoint ?? `${(config.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "")}/v1/auth/centrifugo-token`,
335
- centrifugoWsUrl: config.centrifugoWsUrl ?? "",
234
+ version: config.version ?? config.versionSlug ?? DEFAULT_VERSION_SLUG,
336
235
  env: config.env ?? "prod"
337
236
  };
338
237
  this._transport = config.transport ?? (this._config.missingHandler === "log" ? logTransport : defaultTransport({
@@ -354,11 +253,21 @@ var VerbumiaI18n = class {
354
253
  return {
355
254
  ready: this.ready,
356
255
  locale: this.locale,
256
+ language: this.locale,
357
257
  setLocale: this.setLocale,
258
+ changeLanguage: this.changeLanguage,
259
+ t: this.t,
358
260
  missingEvents: this.missingEvents,
359
- flushMissing: this.flushMissing
261
+ flushMissing: this.flushMissing,
262
+ reload: this.reload
360
263
  };
361
264
  }
265
+ /** Bundle cache-key builder. Includes `version` so providers with
266
+ * different `version` values never share cached bundles. The segments
267
+ * (version/locale/ns) are slugs and never contain '/'. */
268
+ _bundleKey(locale, ns) {
269
+ return `${this._config.version}/${locale}/${ns}`;
270
+ }
362
271
  _notify() {
363
272
  this._snapshot = this._buildSnapshot();
364
273
  for (const l of this._listeners) l();
@@ -381,9 +290,6 @@ var VerbumiaI18n = class {
381
290
  );
382
291
  this.ready = true;
383
292
  this._startTimer();
384
- if (this._config.liveUpdates && this._config.env === "dev") {
385
- this._startLive(fetchImpl);
386
- }
387
293
  this._notify();
388
294
  }
389
295
  setLocale = async (next) => {
@@ -397,102 +303,64 @@ var VerbumiaI18n = class {
397
303
  this.ready = true;
398
304
  this._notify();
399
305
  };
306
+ /** Alias of {@link setLocale} for react-i18next compatibility. */
307
+ changeLanguage = (next) => this.setLocale(next);
308
+ /** Alias of {@link locale} for react-i18next compatibility. */
309
+ get language() {
310
+ return this.locale;
311
+ }
400
312
  stop() {
401
313
  keyRegistry.detach();
402
314
  if (this._timer) {
403
315
  clearInterval(this._timer);
404
316
  this._timer = null;
405
317
  }
406
- if (this._live) {
407
- this._live.dispose();
408
- this._live = null;
409
- }
410
318
  }
411
319
  /**
412
- * Start the Centrifugo subscription and re-fetch the relevant bundle on
413
- * each `translations_published` event. Best-effort: if the WS URL or
414
- * token endpoint isn't reachable, we log silently and the SDK continues
415
- * to serve the initial bundle.
320
+ * Bust-refetch already-loaded bundles and re-render once. Generic
321
+ * replacement for the old realtime-only refetch: iterate the
322
+ * `_attempted` cache keys (`${version}/${locale}/${ns}`), optionally
323
+ * filtered by `opts.locale` / `opts.namespace`, and re-pull each one
324
+ * with `{ bust: true }` so the mutable CDN `latest/` alias bypasses the
325
+ * HTTP cache. After all settle, `_notify()` once so React re-renders.
326
+ *
327
+ * Used by `@verbumia/realtime` on a `translations_published` push and as
328
+ * a manual refresh hook. If nothing matches, returns without notifying.
416
329
  */
417
- _startLive(fetchImpl) {
418
- const wsUrl = this._config.centrifugoWsUrl;
419
- if (!wsUrl) {
420
- if (typeof console !== "undefined") {
421
- console.warn(
422
- "@verbumia/react-i18next: liveUpdates=true but centrifugoWsUrl is empty; skipping subscription."
423
- );
424
- }
425
- return;
330
+ reload = async (opts = {}) => {
331
+ const targets = [];
332
+ for (const key of this._attempted) {
333
+ const parts = key.split("/");
334
+ const locale = parts[1];
335
+ const ns = parts[2];
336
+ if (!locale || !ns) continue;
337
+ if (opts.locale && opts.locale !== locale) continue;
338
+ if (opts.namespace && opts.namespace !== ns) continue;
339
+ targets.push({ locale, ns });
426
340
  }
427
- const projectUuid = this._config.projectUuid;
428
- const tokenEndpoint = this._config.centrifugoTokenEndpoint;
429
- const apiToken = this._config.token;
430
- const refreshToken = async () => {
431
- const { token } = await fetchCentrifugoToken(
432
- tokenEndpoint,
433
- projectUuid,
434
- apiToken,
435
- fetchImpl
436
- );
437
- return token;
438
- };
439
- void (async () => {
440
- let channel;
441
- let token;
442
- try {
443
- const minted = await fetchCentrifugoToken(
444
- tokenEndpoint,
445
- projectUuid,
446
- apiToken,
447
- fetchImpl
448
- );
449
- channel = minted.channel;
450
- token = minted.token;
451
- } catch (err) {
452
- if (typeof console !== "undefined") {
453
- console.warn("@verbumia/react-i18next: live token mint failed", err);
454
- }
455
- return;
456
- }
457
- this._live = new LiveClient({
458
- url: wsUrl,
459
- token,
460
- channel,
461
- refreshToken,
462
- onMessage: (data) => this._onLiveMessage(data, fetchImpl)
463
- });
464
- void this._live.connect();
465
- })();
466
- }
467
- _onLiveMessage(data, fetchImpl) {
468
- if (!data || typeof data !== "object") return;
469
- const d = data;
470
- if (d.event !== "translations_published") return;
471
- const lang = d.language_code;
472
- const ns = d.namespace_slug;
473
- if (!lang || !ns) return;
474
- const cacheKey = `${lang}/${ns}`;
475
- if (!this._attempted.has(cacheKey)) return;
476
- void this._loadBundle(lang, ns, fetchImpl, { bust: true }).then(() => {
477
- this._notify();
478
- });
479
- }
341
+ if (targets.length === 0) return;
342
+ await Promise.all(
343
+ targets.map((t) => this._loadBundle(t.locale, t.ns, fetch, { bust: true }))
344
+ );
345
+ this._notify();
346
+ };
480
347
  // ---- Translation ----
481
- t = (key, options) => {
348
+ t = (key, optionsOrDefault, maybeOptions) => {
349
+ const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
482
350
  const namespace = this._splitNamespace(key);
483
351
  const bareKey = namespace.bareKey;
484
352
  const ns = namespace.ns;
485
- const fromActive = resolve(this._bundles.get(`${this.locale}/${ns}`), bareKey);
353
+ const fromActive = resolve(this._bundles.get(this._bundleKey(this.locale, ns)), bareKey);
486
354
  if (fromActive != null) {
487
355
  return this._render(fromActive, this.locale, options);
488
356
  }
489
357
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
490
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
358
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
491
359
  if (fb != null) {
492
360
  return this._render(fb, this.fallbackLng, options);
493
361
  }
494
362
  }
495
- if (this.ready && this._attempted.has(`${this.locale}/${ns}`) && this._hasContent.has(`${this.locale}/${ns}`)) {
363
+ if (this.ready && this._attempted.has(this._bundleKey(this.locale, ns)) && this._hasContent.has(this._bundleKey(this.locale, ns))) {
496
364
  this._reportMissing({
497
365
  key: bareKey,
498
366
  namespace: ns,
@@ -539,13 +407,13 @@ var VerbumiaI18n = class {
539
407
  return { ns: this._config.namespaces[0], bareKey: key };
540
408
  }
541
409
  async _loadBundle(locale, ns, fetchImpl = fetch, opts = {}) {
542
- const cacheKey = `${locale}/${ns}`;
410
+ const cacheKey = this._bundleKey(locale, ns);
543
411
  let url;
544
412
  let init;
545
413
  if (this._config.env === "dev") {
546
414
  const params = new URLSearchParams({ language: locale, namespace: ns });
547
- if (this._config.versionSlug && this._config.versionSlug !== "main") {
548
- params.set("version_slug", this._config.versionSlug);
415
+ if (this._config.version && this._config.version !== "main") {
416
+ params.set("version_slug", this._config.version);
549
417
  }
550
418
  url = `${this._config.apiBase.replace(/\/+$/, "")}/v1/projects/${this._config.projectUuid}/translations/runtime?${params.toString()}`;
551
419
  init = {
@@ -554,7 +422,7 @@ var VerbumiaI18n = class {
554
422
  credentials: "omit"
555
423
  };
556
424
  } else {
557
- url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.versionSlug}/latest/${locale}/${ns}.json`;
425
+ url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/${locale}/${ns}.json`;
558
426
  init = { method: "GET", credentials: "omit" };
559
427
  }
560
428
  if (opts.bust) {
@@ -609,7 +477,7 @@ var VerbumiaI18n = class {
609
477
  return options.defaultValue;
610
478
  }
611
479
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
612
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
480
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
613
481
  if (typeof fb === "string") {
614
482
  return fb;
615
483
  }
@@ -633,6 +501,23 @@ var VerbumiaI18n = class {
633
501
  }
634
502
  };
635
503
 
504
+ // src/singleton.ts
505
+ var _active = null;
506
+ function _setActiveInstance(instance) {
507
+ _active = instance;
508
+ }
509
+ function _clearActiveInstance(instance) {
510
+ if (_active === instance) _active = null;
511
+ }
512
+ function getI18n() {
513
+ if (!_active) {
514
+ throw new Error(
515
+ "@verbumia/react-i18next: getI18n() was called before <VerbumiaProvider> mounted (no active i18n instance)."
516
+ );
517
+ }
518
+ return _active;
519
+ }
520
+
636
521
  // src/provider.tsx
637
522
  import { jsx, jsxs } from "react/jsx-runtime";
638
523
  var VerbumiaContext = createContext(null);
@@ -642,11 +527,13 @@ function VerbumiaProvider({
642
527
  }) {
643
528
  const i18n = useMemo(() => new VerbumiaI18n(config), []);
644
529
  useEffect(() => {
530
+ _setActiveInstance(i18n);
645
531
  void i18n.start();
646
532
  const teardowns = (config.plugins ?? []).map((p) => p.setup?.({ i18n, config })).filter((t) => typeof t === "function");
647
533
  return () => {
648
534
  teardowns.forEach((t) => t());
649
535
  i18n.stop();
536
+ _clearActiveInstance(i18n);
650
537
  };
651
538
  }, [i18n]);
652
539
  const value = useMemo(() => ({ i18n }), [i18n]);
@@ -678,13 +565,14 @@ function useTranslation(defaultNamespace) {
678
565
  renderedRef.current = /* @__PURE__ */ new Set();
679
566
  const tokenRef = useRef(/* @__PURE__ */ Symbol("verbumia.t"));
680
567
  const t = useMemo2(() => {
681
- return (key, options) => {
568
+ const fn = (key, optionsOrDefault, maybeOptions) => {
682
569
  const fullKey = defaultNamespace && !key.includes(":") ? `${defaultNamespace}:${key}` : key;
683
570
  renderedRef.current.add(
684
571
  keyRegistry.encode(fullKey, i18n.defaultNamespace)
685
572
  );
686
- return i18n.t(fullKey, options);
573
+ return i18n.t(fullKey, optionsOrDefault, maybeOptions);
687
574
  };
575
+ return fn;
688
576
  }, [i18n, defaultNamespace]);
689
577
  useEffect2(() => {
690
578
  keyRegistry._set(tokenRef.current, renderedRef.current);
@@ -739,6 +627,7 @@ export {
739
627
  Trans,
740
628
  VerbumiaProvider,
741
629
  defaultTransport,
630
+ getI18n,
742
631
  keyRegistry,
743
632
  logTransport,
744
633
  useTranslation