@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 +70 -1
- package/dist/index.cjs +63 -201
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -38
- package/dist/index.d.ts +29 -38
- package/dist/index.js +63 -201
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
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
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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.
|
|
558
|
-
params.set("version_slug", this._config.
|
|
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.
|
|
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 (
|
|
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.
|
|
615
|
-
* a
|
|
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(
|
|
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
|
|
489
|
+
return void 0;
|
|
628
490
|
}
|
|
629
491
|
_reportMissing(event) {
|
|
630
492
|
if (this._config.missingHandler === "off") return;
|