@verbumia/react-i18next 0.8.0 → 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
@@ -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:
@@ -212,6 +244,38 @@ import { defaultTransport, logTransport } from "@verbumia/react-i18next";
212
244
 
213
245
  ---
214
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
+
215
279
  ## Recipes
216
280
 
217
281
  ### Next.js (App Router)
package/dist/index.cjs CHANGED
@@ -75,111 +75,6 @@ var logTransport = (batch) => {
75
75
  }
76
76
  };
77
77
 
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
78
  // src/key-registry.ts
184
79
  var GLOBAL = "__verbumia_key_registry__";
185
80
  var SEP = "\0";
@@ -317,10 +212,10 @@ var VerbumiaI18n = class {
317
212
  fallbackLng;
318
213
  missingEvents = [];
319
214
  _bundles = /* @__PURE__ */ new Map();
320
- // `${locale}/${ns}` -> tree
215
+ // `${version}/${locale}/${ns}` -> tree
321
216
  _attempted = /* @__PURE__ */ new Set();
322
- // `${locale}/${ns}` keys we've fetched
323
- // 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)
324
219
  // pairs whose CDN response was 200 with at least one top-level key. An
325
220
  // empty bundle (404 → {} OR 200 → {}) is treated as "no data yet";
326
221
  // calling t() against a key in such a bundle does NOT fire reportMissing.
@@ -335,13 +230,20 @@ var VerbumiaI18n = class {
335
230
  // dedup `${locale}/${ns}/${key}` per-flush
336
231
  _timer = null;
337
232
  _listeners = /* @__PURE__ */ new Set();
338
- _live = null;
339
233
  // Stable snapshot reference for useSyncExternalStore. Returning a fresh
340
234
  // object on each getSnapshot call would loop React forever — we rebuild
341
235
  // it ONLY in _notify (when state actually changed) and return the cached
342
236
  // reference between notifications.
343
237
  _snapshot;
344
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
+ }
345
247
  this.locale = config.defaultLocale;
346
248
  this.fallbackLng = config.fallbackLng;
347
249
  this._config = {
@@ -354,10 +256,7 @@ var VerbumiaI18n = class {
354
256
  flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_MS,
355
257
  flushBatchSize: config.flushBatchSize ?? DEFAULT_BATCH,
356
258
  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 ?? "",
259
+ version: config.version ?? config.versionSlug ?? DEFAULT_VERSION_SLUG,
361
260
  env: config.env ?? "prod"
362
261
  };
363
262
  this._transport = config.transport ?? (this._config.missingHandler === "log" ? logTransport : defaultTransport({
@@ -384,9 +283,16 @@ var VerbumiaI18n = class {
384
283
  changeLanguage: this.changeLanguage,
385
284
  t: this.t,
386
285
  missingEvents: this.missingEvents,
387
- flushMissing: this.flushMissing
286
+ flushMissing: this.flushMissing,
287
+ reload: this.reload
388
288
  };
389
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
+ }
390
296
  _notify() {
391
297
  this._snapshot = this._buildSnapshot();
392
298
  for (const l of this._listeners) l();
@@ -409,9 +315,6 @@ var VerbumiaI18n = class {
409
315
  );
410
316
  this.ready = true;
411
317
  this._startTimer();
412
- if (this._config.liveUpdates && this._config.env === "dev") {
413
- this._startLive(fetchImpl);
414
- }
415
318
  this._notify();
416
319
  }
417
320
  setLocale = async (next) => {
@@ -437,97 +340,52 @@ var VerbumiaI18n = class {
437
340
  clearInterval(this._timer);
438
341
  this._timer = null;
439
342
  }
440
- if (this._live) {
441
- this._live.dispose();
442
- this._live = null;
443
- }
444
343
  }
445
344
  /**
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.
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.
450
354
  */
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;
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 });
460
365
  }
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
- }
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
+ };
514
372
  // ---- Translation ----
515
373
  t = (key, optionsOrDefault, maybeOptions) => {
516
374
  const options = typeof optionsOrDefault === "string" ? { ...maybeOptions ?? {}, defaultValue: optionsOrDefault } : optionsOrDefault;
517
375
  const namespace = this._splitNamespace(key);
518
376
  const bareKey = namespace.bareKey;
519
377
  const ns = namespace.ns;
520
- const fromActive = resolve(this._bundles.get(`${this.locale}/${ns}`), bareKey);
378
+ const fromActive = resolve(this._bundles.get(this._bundleKey(this.locale, ns)), bareKey);
521
379
  if (fromActive != null) {
522
380
  return this._render(fromActive, this.locale, options);
523
381
  }
524
382
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
525
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
383
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
526
384
  if (fb != null) {
527
385
  return this._render(fb, this.fallbackLng, options);
528
386
  }
529
387
  }
530
- 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))) {
531
389
  this._reportMissing({
532
390
  key: bareKey,
533
391
  namespace: ns,
@@ -574,13 +432,13 @@ var VerbumiaI18n = class {
574
432
  return { ns: this._config.namespaces[0], bareKey: key };
575
433
  }
576
434
  async _loadBundle(locale, ns, fetchImpl = fetch, opts = {}) {
577
- const cacheKey = `${locale}/${ns}`;
435
+ const cacheKey = this._bundleKey(locale, ns);
578
436
  let url;
579
437
  let init;
580
438
  if (this._config.env === "dev") {
581
439
  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);
440
+ if (this._config.version && this._config.version !== "main") {
441
+ params.set("version_slug", this._config.version);
584
442
  }
585
443
  url = `${this._config.apiBase.replace(/\/+$/, "")}/v1/projects/${this._config.projectUuid}/translations/runtime?${params.toString()}`;
586
444
  init = {
@@ -589,7 +447,7 @@ var VerbumiaI18n = class {
589
447
  credentials: "omit"
590
448
  };
591
449
  } else {
592
- 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`;
593
451
  init = { method: "GET", credentials: "omit" };
594
452
  }
595
453
  if (opts.bust) {
@@ -644,7 +502,7 @@ var VerbumiaI18n = class {
644
502
  return options.defaultValue;
645
503
  }
646
504
  if (this.fallbackLng && this.fallbackLng !== this.locale) {
647
- const fb = resolve(this._bundles.get(`${this.fallbackLng}/${ns}`), bareKey);
505
+ const fb = resolve(this._bundles.get(this._bundleKey(this.fallbackLng, ns)), bareKey);
648
506
  if (typeof fb === "string") {
649
507
  return fb;
650
508
  }