@verbumia/react-i18next 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  // src/transport.ts
12
12
  var SDK_LIB = "@verbumia/react-i18next";
13
- var SDK_VER = "0.5.2";
13
+ var SDK_VER = true ? "0.9.1" : "0.0.0-dev";
14
14
  function defaultTransport(opts) {
15
15
  return async (batch) => {
16
16
  if (!batch.length) return;
@@ -20,7 +20,9 @@ function defaultTransport(opts) {
20
20
  key: e.key,
21
21
  namespace: e.namespace,
22
22
  language_code: e.language_code,
23
- source_value: e.source_value,
23
+ // Option A (#746): only send source_value when there's a real value;
24
+ // omit it otherwise (never the key name). Absent = "no default".
25
+ ...e.source_value !== void 0 ? { source_value: e.source_value } : {},
24
26
  sdk_meta: {
25
27
  lib: SDK_LIB,
26
28
  ver: SDK_VER,
@@ -50,111 +52,6 @@ var logTransport = (batch) => {
50
52
  }
51
53
  };
52
54
 
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
55
  // src/key-registry.ts
159
56
  var GLOBAL = "__verbumia_key_registry__";
160
57
  var SEP = "\0";
@@ -292,10 +189,10 @@ var VerbumiaI18n = class {
292
189
  fallbackLng;
293
190
  missingEvents = [];
294
191
  _bundles = /* @__PURE__ */ new Map();
295
- // `${locale}/${ns}` -> tree
192
+ // `${version}/${locale}/${ns}` -> tree
296
193
  _attempted = /* @__PURE__ */ new Set();
297
- // `${locale}/${ns}` keys we've fetched
298
- // Tighter gate than `_attempted`: this set only contains (locale, ns)
194
+ // `${version}/${locale}/${ns}` keys we've fetched
195
+ // Tighter gate than `_attempted`: this set only contains (version, locale, ns)
299
196
  // pairs whose CDN response was 200 with at least one top-level key. An
300
197
  // empty bundle (404 → {} OR 200 → {}) is treated as "no data yet";
301
198
  // calling t() against a key in such a bundle does NOT fire reportMissing.
@@ -310,13 +207,20 @@ var VerbumiaI18n = class {
310
207
  // dedup `${locale}/${ns}/${key}` per-flush
311
208
  _timer = null;
312
209
  _listeners = /* @__PURE__ */ new Set();
313
- _live = null;
314
210
  // Stable snapshot reference for useSyncExternalStore. Returning a fresh
315
211
  // object on each getSnapshot call would loop React forever — we rebuild
316
212
  // it ONLY in _notify (when state actually changed) and return the cached
317
213
  // reference between notifications.
318
214
  _snapshot;
319
215
  constructor(config) {
216
+ const removedRealtimeKeys = Object.keys(config).filter(
217
+ (k) => k === "liveUpdates" || k.startsWith("centrifugo")
218
+ );
219
+ if (removedRealtimeKeys.length > 0) {
220
+ throw new Error(
221
+ `@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.`
222
+ );
223
+ }
320
224
  this.locale = config.defaultLocale;
321
225
  this.fallbackLng = config.fallbackLng;
322
226
  this._config = {
@@ -329,10 +233,7 @@ var VerbumiaI18n = class {
329
233
  flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_MS,
330
234
  flushBatchSize: config.flushBatchSize ?? DEFAULT_BATCH,
331
235
  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 ?? "",
236
+ version: config.version ?? config.versionSlug ?? DEFAULT_VERSION_SLUG,
336
237
  env: config.env ?? "prod"
337
238
  };
338
239
  this._transport = config.transport ?? (this._config.missingHandler === "log" ? logTransport : defaultTransport({
@@ -359,9 +260,16 @@ var VerbumiaI18n = class {
359
260
  changeLanguage: this.changeLanguage,
360
261
  t: this.t,
361
262
  missingEvents: this.missingEvents,
362
- flushMissing: this.flushMissing
263
+ flushMissing: this.flushMissing,
264
+ reload: this.reload
363
265
  };
364
266
  }
267
+ /** Bundle cache-key builder. Includes `version` so providers with
268
+ * different `version` values never share cached bundles. The segments
269
+ * (version/locale/ns) are slugs and never contain '/'. */
270
+ _bundleKey(locale, ns) {
271
+ return `${this._config.version}/${locale}/${ns}`;
272
+ }
365
273
  _notify() {
366
274
  this._snapshot = this._buildSnapshot();
367
275
  for (const l of this._listeners) l();
@@ -384,9 +292,6 @@ var VerbumiaI18n = class {
384
292
  );
385
293
  this.ready = true;
386
294
  this._startTimer();
387
- if (this._config.liveUpdates && this._config.env === "dev") {
388
- this._startLive(fetchImpl);
389
- }
390
295
  this._notify();
391
296
  }
392
297
  setLocale = async (next) => {
@@ -412,97 +317,52 @@ var VerbumiaI18n = class {
412
317
  clearInterval(this._timer);
413
318
  this._timer = null;
414
319
  }
415
- if (this._live) {
416
- this._live.dispose();
417
- this._live = null;
418
- }
419
320
  }
420
321
  /**
421
- * Start the Centrifugo subscription and re-fetch the relevant bundle on
422
- * each `translations_published` event. Best-effort: if the WS URL or
423
- * token endpoint isn't reachable, we log silently and the SDK continues
424
- * to serve the initial bundle.
322
+ * Bust-refetch already-loaded bundles and re-render once. Generic
323
+ * replacement for the old realtime-only refetch: iterate the
324
+ * `_attempted` cache keys (`${version}/${locale}/${ns}`), optionally
325
+ * filtered by `opts.locale` / `opts.namespace`, and re-pull each one
326
+ * with `{ bust: true }` so the mutable CDN `latest/` alias bypasses the
327
+ * HTTP cache. After all settle, `_notify()` once so React re-renders.
328
+ *
329
+ * Used by `@verbumia/realtime` on a `translations_published` push and as
330
+ * a manual refresh hook. If nothing matches, returns without notifying.
425
331
  */
426
- _startLive(fetchImpl) {
427
- const wsUrl = this._config.centrifugoWsUrl;
428
- if (!wsUrl) {
429
- if (typeof console !== "undefined") {
430
- console.warn(
431
- "@verbumia/react-i18next: liveUpdates=true but centrifugoWsUrl is empty; skipping subscription."
432
- );
433
- }
434
- return;
332
+ reload = async (opts = {}) => {
333
+ const targets = [];
334
+ for (const key of this._attempted) {
335
+ const parts = key.split("/");
336
+ const locale = parts[1];
337
+ const ns = parts[2];
338
+ if (!locale || !ns) continue;
339
+ if (opts.locale && opts.locale !== locale) continue;
340
+ if (opts.namespace && opts.namespace !== ns) continue;
341
+ targets.push({ locale, ns });
435
342
  }
436
- const projectUuid = this._config.projectUuid;
437
- const tokenEndpoint = this._config.centrifugoTokenEndpoint;
438
- const apiToken = this._config.token;
439
- const refreshToken = async () => {
440
- const { token } = await fetchCentrifugoToken(
441
- tokenEndpoint,
442
- projectUuid,
443
- apiToken,
444
- fetchImpl
445
- );
446
- return token;
447
- };
448
- void (async () => {
449
- let channel;
450
- let token;
451
- try {
452
- const minted = await fetchCentrifugoToken(
453
- tokenEndpoint,
454
- projectUuid,
455
- apiToken,
456
- fetchImpl
457
- );
458
- channel = minted.channel;
459
- token = minted.token;
460
- } catch (err) {
461
- if (typeof console !== "undefined") {
462
- console.warn("@verbumia/react-i18next: live token mint failed", err);
463
- }
464
- return;
465
- }
466
- this._live = new LiveClient({
467
- url: wsUrl,
468
- token,
469
- channel,
470
- refreshToken,
471
- onMessage: (data) => this._onLiveMessage(data, fetchImpl)
472
- });
473
- void this._live.connect();
474
- })();
475
- }
476
- _onLiveMessage(data, fetchImpl) {
477
- if (!data || typeof data !== "object") return;
478
- const d = data;
479
- if (d.event !== "translations_published") return;
480
- const lang = d.language_code;
481
- const ns = d.namespace_slug;
482
- if (!lang || !ns) return;
483
- const cacheKey = `${lang}/${ns}`;
484
- if (!this._attempted.has(cacheKey)) return;
485
- void this._loadBundle(lang, ns, fetchImpl, { bust: true }).then(() => {
486
- this._notify();
487
- });
488
- }
343
+ if (targets.length === 0) return;
344
+ await Promise.all(
345
+ targets.map((t) => this._loadBundle(t.locale, t.ns, fetch, { bust: true }))
346
+ );
347
+ this._notify();
348
+ };
489
349
  // ---- Translation ----
490
350
  t = (key, optionsOrDefault, maybeOptions) => {
491
351
  const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
492
352
  const namespace = this._splitNamespace(key);
493
353
  const bareKey = namespace.bareKey;
494
354
  const ns = namespace.ns;
495
- const fromActive = resolve(this._bundles.get(`${this.locale}/${ns}`), bareKey);
355
+ const fromActive = resolve(this._bundles.get(this._bundleKey(this.locale, ns)), bareKey);
496
356
  if (fromActive != null) {
497
357
  return this._render(fromActive, this.locale, options);
498
358
  }
499
359
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
500
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
360
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
501
361
  if (fb != null) {
502
362
  return this._render(fb, this.fallbackLng, options);
503
363
  }
504
364
  }
505
- if (this.ready && this._attempted.has(`${this.locale}/${ns}`) && this._hasContent.has(`${this.locale}/${ns}`)) {
365
+ if (this.ready && this._attempted.has(this._bundleKey(this.locale, ns)) && this._hasContent.has(this._bundleKey(this.locale, ns))) {
506
366
  this._reportMissing({
507
367
  key: bareKey,
508
368
  namespace: ns,
@@ -549,13 +409,13 @@ var VerbumiaI18n = class {
549
409
  return { ns: this._config.namespaces[0], bareKey: key };
550
410
  }
551
411
  async _loadBundle(locale, ns, fetchImpl = fetch, opts = {}) {
552
- const cacheKey = `${locale}/${ns}`;
412
+ const cacheKey = this._bundleKey(locale, ns);
553
413
  let url;
554
414
  let init;
555
415
  if (this._config.env === "dev") {
556
416
  const params = new URLSearchParams({ language: locale, namespace: ns });
557
- if (this._config.versionSlug && this._config.versionSlug !== "main") {
558
- params.set("version_slug", this._config.versionSlug);
417
+ if (this._config.version && this._config.version !== "main") {
418
+ params.set("version_slug", this._config.version);
559
419
  }
560
420
  url = `${this._config.apiBase.replace(/\/+$/, "")}/v1/projects/${this._config.projectUuid}/translations/runtime?${params.toString()}`;
561
421
  init = {
@@ -564,7 +424,7 @@ var VerbumiaI18n = class {
564
424
  credentials: "omit"
565
425
  };
566
426
  } else {
567
- url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.versionSlug}/latest/${locale}/${ns}.json`;
427
+ url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/${locale}/${ns}.json`;
568
428
  init = { method: "GET", credentials: "omit" };
569
429
  }
570
430
  if (opts.bust) {
@@ -606,25 +466,27 @@ var VerbumiaI18n = class {
606
466
  /**
607
467
  * Resolve the `source_value` we send with a missing-key report.
608
468
  *
609
- * Fallback chain (per backend agreement 2026-05-14, task 575):
469
+ * Fallback chain (Option A, task #746 — backend ingest aligned):
610
470
  * 1. `options.defaultValue` — explicit developer-provided string.
611
471
  * 2. The fallbackLng bundle's value for this key (typically the
612
472
  * source/canonical locale). Only used when it resolves to a
613
473
  * plain string, not a plural CLDR dict.
614
- * 3. The bare key itself last resort so dashboards never render
615
- * a blank `source_value` column.
474
+ * 3. Otherwise `undefined` we DO NOT fall back to the key name (that
475
+ * made the column unusable: a placeholder indistinguishable from a
476
+ * real default). The key is already carried in `event.key`; an absent
477
+ * `source_value` is the signal that there is no promotable value.
616
478
  */
617
479
  _sourceValueFor(bareKey, ns, options) {
618
480
  if (typeof options?.defaultValue === "string") {
619
481
  return options.defaultValue;
620
482
  }
621
483
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
622
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
484
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
623
485
  if (typeof fb === "string") {
624
486
  return fb;
625
487
  }
626
488
  }
627
- return bareKey;
489
+ return void 0;
628
490
  }
629
491
  _reportMissing(event) {
630
492
  if (this._config.missingHandler === "off") return;