@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/README.md CHANGED
@@ -66,6 +66,10 @@ interface VerbumiaConfig {
66
66
  defaultNS?: string; // alias: default namespace for single-ns apps
67
67
  apiBase?: string; // default 'https://api.verbumia.dev'
68
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
69
73
  transport?: (batch: MissingKeyEvent[]) => void | Promise<void>;
70
74
  missingHandler?: 'send' | 'log' | 'off'; // default 'send'
71
75
  flushIntervalMs?: number; // default 5000
@@ -74,6 +78,19 @@ interface VerbumiaConfig {
74
78
  }
75
79
  ```
76
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
+
77
94
  ### `useTranslation(defaultNamespace?)`
78
95
 
79
96
  Returns `{ t, i18n }`.
@@ -95,9 +112,24 @@ interface I18nInstance {
95
112
  t: TranslationFunction; // for out-of-React use via getI18n()
96
113
  missingEvents: MissingKeyEvent[]; // newest first, capped buffer
97
114
  flushMissing(): Promise<void>; // force-flush the pending batch
115
+ reload(opts?: { locale?: string; namespace?: string }): Promise<void>;
98
116
  }
99
117
  ```
100
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
+
101
133
  ### `<Trans>`
102
134
 
103
135
  Inline translation with JSX slots:
@@ -168,11 +200,16 @@ interface MissingKeyEvent {
168
200
  key: string;
169
201
  namespace: string;
170
202
  language_code: string;
171
- source_value?: string;
203
+ source_value?: string; // explicit defaultValue or fallback value; omitted when none (never the key name)
172
204
  sdk_meta?: Record<string, unknown>; // SDK adds {lib, ver, url} automatically
173
205
  }
174
206
  ```
175
207
 
208
+ `source_value` carries the canonical default the SDK has — the `defaultValue`
209
+ you pass to `t()` (object or positional form), or the fallback-language bundle
210
+ value. When there is no default, it is **omitted** (the key name is in `key`),
211
+ so the backend never mistakes a key for a translation.
212
+
176
213
  ### Why the gate matters
177
214
 
178
215
  Without the gate, every `t("…")` call between mount and bundle resolution
@@ -212,6 +249,38 @@ import { defaultTransport, logTransport } from "@verbumia/react-i18next";
212
249
 
213
250
  ---
214
251
 
252
+ ## Realtime updates
253
+
254
+ Zero-deploy translation updates (subscribe to the project's Centrifugo
255
+ `translations:` channel and bust-refetch on publish) live in the separate
256
+ [`@verbumia/realtime`](../realtime) package — added as a **plugin** of this
257
+ provider, not configured here:
258
+
259
+ ```tsx
260
+ import { VerbumiaProvider } from "@verbumia/react-i18next";
261
+ import { verbumiaRealtime } from "@verbumia/realtime/react";
262
+
263
+ <VerbumiaProvider
264
+ {...config}
265
+ env="dev"
266
+ plugins={[
267
+ verbumiaRealtime({ wsUrl: "wss://centrifugo.verbumia.ca/connection/websocket" }),
268
+ ]}
269
+ >
270
+ <App />
271
+ </VerbumiaProvider>;
272
+ ```
273
+
274
+ Under the hood the plugin calls [`i18n.reload(...)`](#i18nreloadopts) on
275
+ each `translations_published` push. Realtime is a dev-version-only feature
276
+ (it only subscribes when `env: "dev"`).
277
+
278
+ > The `liveUpdates` / `centrifugoWsUrl` / `centrifugoTokenEndpoint` config
279
+ > keys were **removed in 0.9.0**. Install `@verbumia/realtime` and use the
280
+ > plugin instead.
281
+
282
+ ---
283
+
215
284
  ## Recipes
216
285
 
217
286
  ### Next.js (App Router)
package/dist/index.cjs CHANGED
@@ -35,7 +35,7 @@ var import_react = require("react");
35
35
 
36
36
  // src/transport.ts
37
37
  var SDK_LIB = "@verbumia/react-i18next";
38
- var SDK_VER = "0.5.2";
38
+ var SDK_VER = true ? "0.9.1" : "0.0.0-dev";
39
39
  function defaultTransport(opts) {
40
40
  return async (batch) => {
41
41
  if (!batch.length) return;
@@ -45,7 +45,9 @@ function defaultTransport(opts) {
45
45
  key: e.key,
46
46
  namespace: e.namespace,
47
47
  language_code: e.language_code,
48
- source_value: e.source_value,
48
+ // Option A (#746): only send source_value when there's a real value;
49
+ // omit it otherwise (never the key name). Absent = "no default".
50
+ ...e.source_value !== void 0 ? { source_value: e.source_value } : {},
49
51
  sdk_meta: {
50
52
  lib: SDK_LIB,
51
53
  ver: SDK_VER,
@@ -75,111 +77,6 @@ var logTransport = (batch) => {
75
77
  }
76
78
  };
77
79
 
78
- // src/live.ts
79
- var LiveClient = class {
80
- constructor(cfg) {
81
- this.cfg = cfg;
82
- }
83
- cfg;
84
- _ws = null;
85
- _id = 0;
86
- _backoffMs = 1e3;
87
- _disposed = false;
88
- _connectAcked = false;
89
- /** Open the socket and try to subscribe. Idempotent — calling twice is a no-op. */
90
- async connect() {
91
- if (this._ws) return;
92
- if (this._disposed) return;
93
- this.cfg.onStatus?.("connecting");
94
- const token = this.cfg.refreshToken ? await this.cfg.refreshToken() : this.cfg.token;
95
- let url = this.cfg.url;
96
- if (!url.includes("/connection/websocket")) {
97
- url = url.replace(/\/+$/, "") + "/connection/websocket";
98
- }
99
- const ws = new WebSocket(url);
100
- this._ws = ws;
101
- this._connectAcked = false;
102
- ws.onopen = () => {
103
- this._send({ id: ++this._id, connect: { token } });
104
- };
105
- ws.onmessage = (evt) => this._onFrame(evt.data);
106
- ws.onclose = () => this._onClose();
107
- ws.onerror = () => {
108
- };
109
- }
110
- dispose() {
111
- this._disposed = true;
112
- if (this._ws) {
113
- try {
114
- this._ws.close();
115
- } catch {
116
- }
117
- this._ws = null;
118
- }
119
- this.cfg.onStatus?.("disconnected");
120
- }
121
- // ---- internals ----
122
- _send(msg) {
123
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
124
- try {
125
- this._ws.send(JSON.stringify(msg));
126
- } catch {
127
- }
128
- }
129
- _onFrame(raw) {
130
- let parsed;
131
- try {
132
- parsed = JSON.parse(raw);
133
- } catch {
134
- return;
135
- }
136
- if (!parsed || typeof parsed !== "object") return;
137
- if (Object.keys(parsed).length === 0) {
138
- this._send({});
139
- return;
140
- }
141
- if (parsed.connect && !this._connectAcked) {
142
- this._connectAcked = true;
143
- this.cfg.onStatus?.("connected");
144
- this._backoffMs = 1e3;
145
- this._send({
146
- id: ++this._id,
147
- subscribe: { channel: this.cfg.channel }
148
- });
149
- return;
150
- }
151
- const push = parsed.push;
152
- if (push && push.channel === this.cfg.channel && push.pub) {
153
- this.cfg.onMessage(push.pub.data);
154
- }
155
- }
156
- _onClose() {
157
- this._ws = null;
158
- this._connectAcked = false;
159
- this.cfg.onStatus?.("disconnected");
160
- if (this._disposed) return;
161
- const delay = this._backoffMs;
162
- this._backoffMs = Math.min(this._backoffMs * 2, 3e4);
163
- setTimeout(() => {
164
- if (!this._disposed) void this.connect();
165
- }, delay);
166
- }
167
- };
168
- async function fetchCentrifugoToken(endpoint, projectUuid, authToken, fetchImpl = fetch) {
169
- const r = await fetchImpl(endpoint, {
170
- method: "POST",
171
- headers: {
172
- "Content-Type": "application/json",
173
- Authorization: `ApiKey ${authToken}`
174
- },
175
- body: JSON.stringify({ project_uuid: projectUuid })
176
- });
177
- if (!r.ok) {
178
- throw new Error(`centrifugo-token endpoint ${r.status}: ${await r.text()}`);
179
- }
180
- return await r.json();
181
- }
182
-
183
80
  // src/key-registry.ts
184
81
  var GLOBAL = "__verbumia_key_registry__";
185
82
  var SEP = "\0";
@@ -317,10 +214,10 @@ var VerbumiaI18n = class {
317
214
  fallbackLng;
318
215
  missingEvents = [];
319
216
  _bundles = /* @__PURE__ */ new Map();
320
- // `${locale}/${ns}` -> tree
217
+ // `${version}/${locale}/${ns}` -> tree
321
218
  _attempted = /* @__PURE__ */ new Set();
322
- // `${locale}/${ns}` keys we've fetched
323
- // Tighter gate than `_attempted`: this set only contains (locale, ns)
219
+ // `${version}/${locale}/${ns}` keys we've fetched
220
+ // Tighter gate than `_attempted`: this set only contains (version, locale, ns)
324
221
  // pairs whose CDN response was 200 with at least one top-level key. An
325
222
  // empty bundle (404 → {} OR 200 → {}) is treated as "no data yet";
326
223
  // calling t() against a key in such a bundle does NOT fire reportMissing.
@@ -335,13 +232,20 @@ var VerbumiaI18n = class {
335
232
  // dedup `${locale}/${ns}/${key}` per-flush
336
233
  _timer = null;
337
234
  _listeners = /* @__PURE__ */ new Set();
338
- _live = null;
339
235
  // Stable snapshot reference for useSyncExternalStore. Returning a fresh
340
236
  // object on each getSnapshot call would loop React forever — we rebuild
341
237
  // it ONLY in _notify (when state actually changed) and return the cached
342
238
  // reference between notifications.
343
239
  _snapshot;
344
240
  constructor(config) {
241
+ const removedRealtimeKeys = Object.keys(config).filter(
242
+ (k) => k === "liveUpdates" || k.startsWith("centrifugo")
243
+ );
244
+ if (removedRealtimeKeys.length > 0) {
245
+ throw new Error(
246
+ `@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.`
247
+ );
248
+ }
345
249
  this.locale = config.defaultLocale;
346
250
  this.fallbackLng = config.fallbackLng;
347
251
  this._config = {
@@ -354,10 +258,7 @@ var VerbumiaI18n = class {
354
258
  flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_MS,
355
259
  flushBatchSize: config.flushBatchSize ?? DEFAULT_BATCH,
356
260
  missingEventsBufferSize: config.missingEventsBufferSize ?? DEFAULT_BUFFER,
357
- versionSlug: config.versionSlug ?? DEFAULT_VERSION_SLUG,
358
- liveUpdates: !!config.liveUpdates,
359
- centrifugoTokenEndpoint: config.centrifugoTokenEndpoint ?? `${(config.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "")}/v1/auth/centrifugo-token`,
360
- centrifugoWsUrl: config.centrifugoWsUrl ?? "",
261
+ version: config.version ?? config.versionSlug ?? DEFAULT_VERSION_SLUG,
361
262
  env: config.env ?? "prod"
362
263
  };
363
264
  this._transport = config.transport ?? (this._config.missingHandler === "log" ? logTransport : defaultTransport({
@@ -384,9 +285,16 @@ var VerbumiaI18n = class {
384
285
  changeLanguage: this.changeLanguage,
385
286
  t: this.t,
386
287
  missingEvents: this.missingEvents,
387
- flushMissing: this.flushMissing
288
+ flushMissing: this.flushMissing,
289
+ reload: this.reload
388
290
  };
389
291
  }
292
+ /** Bundle cache-key builder. Includes `version` so providers with
293
+ * different `version` values never share cached bundles. The segments
294
+ * (version/locale/ns) are slugs and never contain '/'. */
295
+ _bundleKey(locale, ns) {
296
+ return `${this._config.version}/${locale}/${ns}`;
297
+ }
390
298
  _notify() {
391
299
  this._snapshot = this._buildSnapshot();
392
300
  for (const l of this._listeners) l();
@@ -409,9 +317,6 @@ var VerbumiaI18n = class {
409
317
  );
410
318
  this.ready = true;
411
319
  this._startTimer();
412
- if (this._config.liveUpdates && this._config.env === "dev") {
413
- this._startLive(fetchImpl);
414
- }
415
320
  this._notify();
416
321
  }
417
322
  setLocale = async (next) => {
@@ -437,97 +342,52 @@ var VerbumiaI18n = class {
437
342
  clearInterval(this._timer);
438
343
  this._timer = null;
439
344
  }
440
- if (this._live) {
441
- this._live.dispose();
442
- this._live = null;
443
- }
444
345
  }
445
346
  /**
446
- * Start the Centrifugo subscription and re-fetch the relevant bundle on
447
- * each `translations_published` event. Best-effort: if the WS URL or
448
- * token endpoint isn't reachable, we log silently and the SDK continues
449
- * to serve the initial bundle.
347
+ * Bust-refetch already-loaded bundles and re-render once. Generic
348
+ * replacement for the old realtime-only refetch: iterate the
349
+ * `_attempted` cache keys (`${version}/${locale}/${ns}`), optionally
350
+ * filtered by `opts.locale` / `opts.namespace`, and re-pull each one
351
+ * with `{ bust: true }` so the mutable CDN `latest/` alias bypasses the
352
+ * HTTP cache. After all settle, `_notify()` once so React re-renders.
353
+ *
354
+ * Used by `@verbumia/realtime` on a `translations_published` push and as
355
+ * a manual refresh hook. If nothing matches, returns without notifying.
450
356
  */
451
- _startLive(fetchImpl) {
452
- const wsUrl = this._config.centrifugoWsUrl;
453
- if (!wsUrl) {
454
- if (typeof console !== "undefined") {
455
- console.warn(
456
- "@verbumia/react-i18next: liveUpdates=true but centrifugoWsUrl is empty; skipping subscription."
457
- );
458
- }
459
- return;
357
+ reload = async (opts = {}) => {
358
+ const targets = [];
359
+ for (const key of this._attempted) {
360
+ const parts = key.split("/");
361
+ const locale = parts[1];
362
+ const ns = parts[2];
363
+ if (!locale || !ns) continue;
364
+ if (opts.locale && opts.locale !== locale) continue;
365
+ if (opts.namespace && opts.namespace !== ns) continue;
366
+ targets.push({ locale, ns });
460
367
  }
461
- const projectUuid = this._config.projectUuid;
462
- const tokenEndpoint = this._config.centrifugoTokenEndpoint;
463
- const apiToken = this._config.token;
464
- const refreshToken = async () => {
465
- const { token } = await fetchCentrifugoToken(
466
- tokenEndpoint,
467
- projectUuid,
468
- apiToken,
469
- fetchImpl
470
- );
471
- return token;
472
- };
473
- void (async () => {
474
- let channel;
475
- let token;
476
- try {
477
- const minted = await fetchCentrifugoToken(
478
- tokenEndpoint,
479
- projectUuid,
480
- apiToken,
481
- fetchImpl
482
- );
483
- channel = minted.channel;
484
- token = minted.token;
485
- } catch (err) {
486
- if (typeof console !== "undefined") {
487
- console.warn("@verbumia/react-i18next: live token mint failed", err);
488
- }
489
- return;
490
- }
491
- this._live = new LiveClient({
492
- url: wsUrl,
493
- token,
494
- channel,
495
- refreshToken,
496
- onMessage: (data) => this._onLiveMessage(data, fetchImpl)
497
- });
498
- void this._live.connect();
499
- })();
500
- }
501
- _onLiveMessage(data, fetchImpl) {
502
- if (!data || typeof data !== "object") return;
503
- const d = data;
504
- if (d.event !== "translations_published") return;
505
- const lang = d.language_code;
506
- const ns = d.namespace_slug;
507
- if (!lang || !ns) return;
508
- const cacheKey = `${lang}/${ns}`;
509
- if (!this._attempted.has(cacheKey)) return;
510
- void this._loadBundle(lang, ns, fetchImpl, { bust: true }).then(() => {
511
- this._notify();
512
- });
513
- }
368
+ if (targets.length === 0) return;
369
+ await Promise.all(
370
+ targets.map((t) => this._loadBundle(t.locale, t.ns, fetch, { bust: true }))
371
+ );
372
+ this._notify();
373
+ };
514
374
  // ---- Translation ----
515
375
  t = (key, optionsOrDefault, maybeOptions) => {
516
376
  const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
517
377
  const namespace = this._splitNamespace(key);
518
378
  const bareKey = namespace.bareKey;
519
379
  const ns = namespace.ns;
520
- const fromActive = resolve(this._bundles.get(`${this.locale}/${ns}`), bareKey);
380
+ const fromActive = resolve(this._bundles.get(this._bundleKey(this.locale, ns)), bareKey);
521
381
  if (fromActive != null) {
522
382
  return this._render(fromActive, this.locale, options);
523
383
  }
524
384
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
525
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
385
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
526
386
  if (fb != null) {
527
387
  return this._render(fb, this.fallbackLng, options);
528
388
  }
529
389
  }
530
- if (this.ready && this._attempted.has(`${this.locale}/${ns}`) && this._hasContent.has(`${this.locale}/${ns}`)) {
390
+ if (this.ready && this._attempted.has(this._bundleKey(this.locale, ns)) && this._hasContent.has(this._bundleKey(this.locale, ns))) {
531
391
  this._reportMissing({
532
392
  key: bareKey,
533
393
  namespace: ns,
@@ -574,13 +434,13 @@ var VerbumiaI18n = class {
574
434
  return { ns: this._config.namespaces[0], bareKey: key };
575
435
  }
576
436
  async _loadBundle(locale, ns, fetchImpl = fetch, opts = {}) {
577
- const cacheKey = `${locale}/${ns}`;
437
+ const cacheKey = this._bundleKey(locale, ns);
578
438
  let url;
579
439
  let init;
580
440
  if (this._config.env === "dev") {
581
441
  const params = new URLSearchParams({ language: locale, namespace: ns });
582
- if (this._config.versionSlug && this._config.versionSlug !== "main") {
583
- params.set("version_slug", this._config.versionSlug);
442
+ if (this._config.version && this._config.version !== "main") {
443
+ params.set("version_slug", this._config.version);
584
444
  }
585
445
  url = `${this._config.apiBase.replace(/\/+$/, "")}/v1/projects/${this._config.projectUuid}/translations/runtime?${params.toString()}`;
586
446
  init = {
@@ -589,7 +449,7 @@ var VerbumiaI18n = class {
589
449
  credentials: "omit"
590
450
  };
591
451
  } else {
592
- url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.versionSlug}/latest/${locale}/${ns}.json`;
452
+ url = `${this._config.cdnBase.replace(/\/+$/, "")}/p/${this._config.projectUuid}/${this._config.version}/latest/${locale}/${ns}.json`;
593
453
  init = { method: "GET", credentials: "omit" };
594
454
  }
595
455
  if (opts.bust) {
@@ -631,25 +491,27 @@ var VerbumiaI18n = class {
631
491
  /**
632
492
  * Resolve the `source_value` we send with a missing-key report.
633
493
  *
634
- * Fallback chain (per backend agreement 2026-05-14, task 575):
494
+ * Fallback chain (Option A, task #746 — backend ingest aligned):
635
495
  * 1. `options.defaultValue` — explicit developer-provided string.
636
496
  * 2. The fallbackLng bundle's value for this key (typically the
637
497
  * source/canonical locale). Only used when it resolves to a
638
498
  * plain string, not a plural CLDR dict.
639
- * 3. The bare key itself last resort so dashboards never render
640
- * a blank `source_value` column.
499
+ * 3. Otherwise `undefined` we DO NOT fall back to the key name (that
500
+ * made the column unusable: a placeholder indistinguishable from a
501
+ * real default). The key is already carried in `event.key`; an absent
502
+ * `source_value` is the signal that there is no promotable value.
641
503
  */
642
504
  _sourceValueFor(bareKey, ns, options) {
643
505
  if (typeof options?.defaultValue === "string") {
644
506
  return options.defaultValue;
645
507
  }
646
508
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
647
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
509
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
648
510
  if (typeof fb === "string") {
649
511
  return fb;
650
512
  }
651
513
  }
652
- return bareKey;
514
+ return void 0;
653
515
  }
654
516
  _reportMissing(event) {
655
517
  if (this._config.missingHandler === "off") return;