@sonenta/react-i18next 2.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Verbumia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,540 @@
1
+ # @sonenta/react-i18next
2
+
3
+ [![MIT licensed](https://img.shields.io/npm/l/@sonenta/react-i18next.svg)](./LICENSE)
4
+
5
+ The React SDK for [Sonenta](https://verbumia.ca). Resolve translations from
6
+ the Sonenta CDN, fall back gracefully when a key is missing, and stream those
7
+ missing keys back to your dashboard in real time so the team can fill them
8
+ without redeploying.
9
+
10
+ ```bash
11
+ npm install @sonenta/react-i18next
12
+ ```
13
+
14
+ - ✦ Zero-config CDN fetch (Bunny.net edge)
15
+ - ✦ Built-in missing-key handler with first-paint anti-spam gate
16
+ - ✦ Pluggable transport for Storybook / inspectors
17
+ - ✦ < 10 KB ESM (gzipped much smaller), tree-shakeable
18
+ - ✦ Plain `t()` + `<Trans>` semantics — drop-in for most i18next codebases
19
+
20
+ ---
21
+
22
+ ## Quickstart
23
+
24
+ ```tsx
25
+ import { SonentaProvider, useTranslation } from "@sonenta/react-i18next";
26
+
27
+ export function App() {
28
+ return (
29
+ <SonentaProvider
30
+ token={import.meta.env.VITE_VERBUMIA_TOKEN}
31
+ projectUuid={import.meta.env.VITE_VERBUMIA_PROJECT}
32
+ defaultLocale="fr"
33
+ fallbackLng="en"
34
+ namespaces={["common"]}
35
+ >
36
+ <Hello />
37
+ </SonentaProvider>
38
+ );
39
+ }
40
+
41
+ function Hello() {
42
+ const { t, i18n } = useTranslation("common");
43
+ if (!i18n.ready) return <span>Loading…</span>;
44
+ return <h1>{t("hello.title", { name: "Marc", defaultValue: "Hello {{name}}" })}</h1>;
45
+ }
46
+ ```
47
+
48
+ The token is the API key minted in **Org Settings → API Keys**. For the
49
+ browser SDK use a **project-scoped** key with the `missing:write` scope and
50
+ nothing else — that key only sees missing-key writes for one project, which
51
+ is the safest exposure profile.
52
+
53
+ ---
54
+
55
+ ## API surface
56
+
57
+ ### `SonentaProvider`
58
+
59
+ ```ts
60
+ interface SonentaConfig {
61
+ token: string; // vrb_live_<prefix>.<secret>
62
+ projectUuid: string;
63
+ defaultLocale: string; // BCP-47 (e.g. "fr", "fr-CA")
64
+ fallbackLng?: string | string[]; // fallback locale(s); variants also fall back to their base (fr-CA → fr)
65
+ namespaces?: string[]; // default ['common']
66
+ defaultNS?: string; // alias: default namespace for single-ns apps
67
+ apiBase?: string; // default 'https://api.verbumia.dev'
68
+ cdnBase?: string; // default 'https://cdn.verbumia.ca'
69
+ languageCatalog?: LanguageMeta[]; // embed the language catalog (offline/SSR/RN); powers dir()/nativeName()
70
+ disableLanguageCatalog?: boolean; // skip the public GET /v1/languages fetch
71
+ version?: string; // version slug, default 'main' (in cache keys)
72
+ versionSlug?: string; // @deprecated alias of `version`
73
+ env?: 'prod' | 'dev'; // default 'prod' (drives fetch source)
74
+ keySeparator?: string | false; // false = flat (literal keys); else split (default '.'); auto-detected from the version when omitted
75
+ nsSeparator?: string | false; // 'ns:key' separator (default ':'); false to disable (keys may contain ':')
76
+ initialBundles?: Record<string, Record<string, object>>; // build-time snapshot (locale->ns->tree)
77
+ plugins?: SonentaPlugin[]; // e.g. @sonenta/feedback, @sonenta/realtime
78
+ transport?: (batch: MissingKeyEvent[]) => void | Promise<void>;
79
+ missingHandler?: 'send' | 'log' | 'off'; // default 'send'
80
+ flushIntervalMs?: number; // default 5000
81
+ flushBatchSize?: number; // default 50
82
+ missingEventsBufferSize?: number; // default 200
83
+ }
84
+ ```
85
+
86
+ `version` selects which published version's bundles to load
87
+ (`/p/<project>/<version>/latest/...`); it defaults to `'main'` and is part
88
+ of the SDK's bundle cache keys, so two providers with different `version`
89
+ values never share cached bundles. `versionSlug` is a **deprecated** alias
90
+ of `version` (if both are set, `version` wins).
91
+
92
+ > **Removed in 0.9.0:** the `liveUpdates` / `centrifugoWsUrl` /
93
+ > `centrifugoTokenEndpoint` config keys. Realtime updates now live in the
94
+ > separate [`@sonenta/realtime`](../realtime) plugin (see
95
+ > [Realtime updates](#realtime-updates)). Passing any of those keys throws
96
+ > a clear migration error.
97
+ ```
98
+
99
+ ### `useTranslation(defaultNamespace?)`
100
+
101
+ Returns `{ t, i18n }`.
102
+
103
+ ```ts
104
+ // Two call shapes — the native object form AND the react-i18next-style
105
+ // positional fallback (a string 2nd arg is the default value):
106
+ type TranslationFunction = {
107
+ (key: string, defaultValue: string, options?: Record<string, unknown>): string;
108
+ (key: string, options?: Record<string, unknown> & { defaultValue?: string }): string;
109
+ };
110
+
111
+ interface I18nInstance {
112
+ ready: boolean;
113
+ locale: string;
114
+ language: string; // alias of `locale`
115
+ setLocale(next: string): Promise<void>;
116
+ changeLanguage(next: string): Promise<void>; // alias of `setLocale`
117
+ t: TranslationFunction; // for out-of-React use via getI18n()
118
+ missingEvents: MissingKeyEvent[]; // newest first, capped buffer
119
+ flushMissing(): Promise<void>; // force-flush the pending batch
120
+ reload(opts?: { locale?: string; namespace?: string }): Promise<void>;
121
+ dir(lng?: string): 'ltr' | 'rtl'; // text direction (i18next parity); default active locale
122
+ nativeName(lng?: string): string | undefined; // endonym fallback when no Intl.DisplayNames
123
+ languageMeta(lng?: string): LanguageMeta | undefined; // full catalog entry
124
+ }
125
+ ```
126
+
127
+ ### `i18n.reload(opts?)`
128
+
129
+ Bust-refetches already-loaded bundles (bypassing the browser HTTP cache)
130
+ and re-renders. Without `opts` it refreshes every loaded `(locale, ns)`
131
+ bundle; pass `{ locale }` and/or `{ namespace }` to narrow. Returns once
132
+ all refetches settle. Useful for a manual "refresh translations" button,
133
+ and it's what [`@sonenta/realtime`](#realtime-updates) calls on a
134
+ `translations_published` push.
135
+
136
+ ### Regional variants, direction & native names
137
+
138
+ **Variant fallback chain.** An active regional variant resolves through its
139
+ base language automatically before the configured `fallbackLng` — the
140
+ `fr-CA → fr → source` chain (native i18next semantics; multi-subtag locales
141
+ truncate progressively, e.g. `zh-Hant-TW → zh-Hant → zh`). Set `fallbackLng`
142
+ to your project's **source** language to terminate the chain there:
143
+
144
+ ```tsx
145
+ <SonentaProvider {...config} defaultLocale="fr-CA" fallbackLng="en">
146
+ ```
147
+
148
+ The CDN already serves a variant as a fully merged bundle, so this is
149
+ defense-in-depth (and it also covers keys you serve from `initialBundles`).
150
+ `fallbackLng` also accepts an ordered chain: `fallbackLng={['fr', 'en']}`.
151
+
152
+ **Direction (RTL).** `i18n.dir(lng?)` returns `'ltr' | 'rtl'` for a locale
153
+ (default: active), so you can drive `<html dir>` or a container's `dir`:
154
+
155
+ ```tsx
156
+ const { i18n } = useTranslation();
157
+ useEffect(() => { document.documentElement.dir = i18n.dir(); }, [i18n.language]);
158
+ ```
159
+
160
+ It reads the public language catalog's `rtl` (variants inherit from their base),
161
+ falling back to a built-in RTL-language list before/without the catalog.
162
+
163
+ **Native names.** `i18n.nativeName(lng?)` returns a language's endonym (e.g.
164
+ `français (Canada)`) — the fallback for runtimes **without
165
+ `Intl.DisplayNames`** (React Native/Hermes, SSR). For UI-localized names,
166
+ prefer `Intl.DisplayNames(uiLocale, { type: 'language' }).of(code)` and fall
167
+ back to `nativeName()` when it is unavailable. `i18n.languageMeta(lng?)`
168
+ returns the full catalog entry (`rtl`, `script`, `parent_code`,
169
+ `plural_categories`, …).
170
+
171
+ These read a small **public** catalog (`GET {apiBase}/v1/languages`, no auth,
172
+ CDN-cached) fetched best-effort on `start()`. Embed it with
173
+ `languageCatalog={[…]}` for offline/SSR/React Native, or skip the fetch with
174
+ `disableLanguageCatalog`.
175
+
176
+ ```ts
177
+ await getI18n().reload(); // refresh all
178
+ await getI18n().reload({ locale: "fr", namespace: "common" });
179
+ ```
180
+
181
+ ### `<Trans>`
182
+
183
+ Inline translation with JSX slots:
184
+
185
+ ```tsx
186
+ <Trans
187
+ i18nKey="cta.terms"
188
+ defaults="I accept the <0>terms</0> and <1>privacy policy</1>"
189
+ components={[<a href="/terms" />, <a href="/privacy" />]}
190
+ />
191
+ ```
192
+
193
+ The `<0>...</0>` slots are 0-indexed into `components`. The bundle string
194
+ should follow the same shape so that translators see `I accept the <0>terms</0>...`
195
+ and the SDK swaps the elements at render time.
196
+
197
+ ---
198
+
199
+ ## Migrating from react-i18next
200
+
201
+ `@sonenta/react-i18next` is built to be a near drop-in for react-i18next, so
202
+ existing codebases migrate with minimal changes:
203
+
204
+ - **Positional default value** — `t('key', 'Default text')` works (so does
205
+ `t('key', 'Hi {{name}}', { name })`), alongside the native
206
+ `t('key', { defaultValue })`. No codemod needed for inline fallbacks.
207
+ - **`changeLanguage` / `language`** — `i18n.changeLanguage('en')` (alias of
208
+ `setLocale`) and the `i18n.language` getter (alias of `locale`) are available.
209
+ - **Out-of-React access** — `getI18n()` returns the active instance for use in
210
+ plain modules, stores, or helpers (the react-i18next standalone-singleton
211
+ pattern):
212
+
213
+ ```ts
214
+ import { getI18n } from "@sonenta/react-i18next";
215
+ // anywhere after <SonentaProvider> has mounted:
216
+ const label = getI18n().t("nav.home", "Home");
217
+ await getI18n().changeLanguage("en");
218
+ ```
219
+
220
+ `getI18n()` throws a clear error if no provider is mounted yet, and assumes a
221
+ single app-wide provider.
222
+ - **Default namespace** — the default is `['common']` (not react-i18next's
223
+ `'translation'`). Migrants pass `namespaces={['translation']}`, or the
224
+ `defaultNS="translation"` alias for single-namespace apps.
225
+
226
+ ### Not yet supported (planned for V1.1)
227
+
228
+ Plurals and context are **not** resolved yet: `t('key', { count })` performs
229
+ interpolation only — it does **not** select plural keys (`key_one` /
230
+ `key_other`) or context keys (`key_male`). Handle these manually until V1.1.
231
+
232
+ ### Drop-in imports + `@sonenta/feedback` (1.0.3+)
233
+
234
+ The drop-in path lets hosts keep their `from 'react-i18next'` imports
235
+ verbatim — the shared i18next instance resolves them correctly. From
236
+ **1.0.3** onwards, those native calls also feed the on-screen key registry
237
+ that `@sonenta/feedback` reads, so the widget lists the strings rendered
238
+ on the current view in either import shape:
239
+
240
+ ```ts
241
+ // works (sonenta hook — strict per-view drop semantics)
242
+ import { useTranslation } from "@sonenta/react-i18next";
243
+
244
+ // also works (native hook — registry fed via the instance-level wrap)
245
+ import { useTranslation } from "react-i18next";
246
+ ```
247
+
248
+ The trade-off for the native path: the registry **accumulates** for the
249
+ i18n instance's lifetime (no per-component unmount signal to drop from),
250
+ so after several view changes the widget may list a few extra keys from
251
+ prior views. A strictly empty widget on a populated view is no longer
252
+ possible. Hosts that want strict per-view scoping can either:
253
+
254
+ - adopt `@sonenta/react-i18next`'s `useTranslation` (mount-tracked, drops
255
+ on unmount), via the codemod below;
256
+ - or call `keyRegistry.reset()` from their router on navigation.
257
+
258
+ **Codemod** — switch all imports in one shot (re-run as needed):
259
+
260
+ ```bash
261
+ grep -rl "from 'react-i18next'" src \
262
+ | xargs sed -i '' "s|from 'react-i18next'|from '@sonenta/react-i18next'|g"
263
+ ```
264
+
265
+ **Dev-time assertion** — confirm wiring at boot:
266
+
267
+ ```ts
268
+ import { keyRegistry } from "@sonenta/react-i18next";
269
+
270
+ if (__DEV__ && !keyRegistry.isPopulated()) {
271
+ console.warn(
272
+ "@sonenta: no on-screen keys yet — render a screen with t() first, " +
273
+ "or check that your useTranslation imports resolve to @sonenta/react-i18next " +
274
+ "(or to the native react-i18next bound to the exposed i18next instance).",
275
+ );
276
+ }
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Flat vs nested keys
282
+
283
+ By default keys are **nested** and split on `.` — `t("hero.title")` reads
284
+ `{ hero: { title } }`. If your project stores **flat** keys (literal keys that
285
+ may contain dots, e.g. `"App Version 6.3.8"`), set `keySeparator={false}` so
286
+ keys are looked up verbatim:
287
+
288
+ ```tsx
289
+ <SonentaProvider {...config} keySeparator={false}>
290
+ ```
291
+
292
+ - `keySeparator={false}` — flat (literal keys; dotted keys work, never split).
293
+ - `keySeparator="."` (default) or any string — nested, split on it.
294
+ - **Omitted** — the SDK auto-detects the project's `key_style` / `key_separator`
295
+ from the version metadata on mount (best-effort; needs an API key with
296
+ `project:read`, otherwise it falls back to nested `"."`). Set `keySeparator`
297
+ explicitly to skip that lookup and guarantee the style.
298
+
299
+ Resolution is **literal-first**: an exact `bundle[key]` always wins, so a dotted
300
+ key resolves even in nested mode without config (the nested split is the
301
+ fallback). The namespace separator is configurable too — `nsSeparator` (default
302
+ `":"`; `false` disables `"ns:key"` parsing so keys may contain `":"`).
303
+
304
+ ## Missing-key flow
305
+
306
+ 1. The user navigates a page that calls `t("hello.title")`.
307
+ 2. The bundle for `(locale, namespace)` was already fetched but doesn't
308
+ contain `hello.title`. (`i18n.ready === true` and the bundle for that
309
+ tuple is in the "attempted" set — this is the **gate**.)
310
+ 3. The SDK enqueues a `MissingKeyEvent`, dedups it within the instance, and
311
+ pushes it into the `missingEvents` ring buffer.
312
+ 4. Every `flushIntervalMs` (default 5s) — or sooner if the batch hits
313
+ `flushBatchSize` (default 50) — the SDK flushes the pending batch via
314
+ the transport.
315
+
316
+ ```ts
317
+ interface MissingKeyEvent {
318
+ key: string;
319
+ namespace: string;
320
+ language_code: string;
321
+ source_value?: string; // explicit defaultValue or fallback value; omitted when none (never the key name)
322
+ sdk_meta?: Record<string, unknown>; // SDK adds {lib, ver, url} automatically
323
+ }
324
+ ```
325
+
326
+ `source_value` carries the canonical default the SDK has — the `defaultValue`
327
+ you pass to `t()` (object or positional form), or the fallback-language bundle
328
+ value. When there is no default, it is **omitted** (the key name is in `key`),
329
+ so the backend never mistakes a key for a translation.
330
+
331
+ ### Why the gate matters
332
+
333
+ Without the gate, every `t("…")` call between mount and bundle resolution
334
+ would report a "missing" key — which is a lie (the bundle just hadn't
335
+ arrived yet). The first-paint flood would poison your dashboard. The SDK
336
+ holds reports until both:
337
+
338
+ - `i18n.ready === true` (initial bundles loaded), AND
339
+ - the specific `(locale, namespace)` bundle was actually fetched.
340
+
341
+ You can see the gate in action with `i18n.missingEvents` — it stays empty
342
+ until the network round-trip completes.
343
+
344
+ ---
345
+
346
+ ## Custom transport
347
+
348
+ Replace the default POST with anything — Storybook mock, in-app inspector,
349
+ Cypress capture:
350
+
351
+ ```tsx
352
+ <SonentaProvider
353
+ {...config}
354
+ transport={(batch) => {
355
+ window.parent.postMessage({ type: "sonenta:missing", batch }, "*");
356
+ }}
357
+ >
358
+ ...
359
+ </SonentaProvider>
360
+ ```
361
+
362
+ The default delivery path is also exported if you need to wrap it:
363
+
364
+ ```ts
365
+ import { defaultTransport, logTransport } from "@sonenta/react-i18next";
366
+ ```
367
+
368
+ ---
369
+
370
+ ## Realtime updates
371
+
372
+ Zero-deploy translation updates (subscribe to the project's Centrifugo
373
+ `translations:` channel and bust-refetch on publish) live in the separate
374
+ [`@sonenta/realtime`](../realtime) package — added as a **plugin** of this
375
+ provider, not configured here:
376
+
377
+ ```tsx
378
+ import { SonentaProvider } from "@sonenta/react-i18next";
379
+ import { sonentaRealtime } from "@sonenta/realtime/react";
380
+
381
+ <SonentaProvider
382
+ {...config}
383
+ env="dev"
384
+ plugins={[
385
+ sonentaRealtime({ wsUrl: "wss://rt.verbumia.ca/connection/websocket" }),
386
+ ]}
387
+ >
388
+ <App />
389
+ </SonentaProvider>;
390
+ ```
391
+
392
+ Under the hood the plugin calls [`i18n.reload(...)`](#i18nreloadopts) on
393
+ each `translations_published` push. Realtime is a dev-version-only feature
394
+ (it only subscribes when `env: "dev"`).
395
+
396
+ > The `liveUpdates` / `centrifugoWsUrl` / `centrifugoTokenEndpoint` config
397
+ > keys were **removed in 0.9.0**. Install `@sonenta/realtime` and use the
398
+ > plugin instead.
399
+
400
+ ---
401
+
402
+ ## Offline / first-paint snapshot
403
+
404
+ Native apps (and SSR/web) can render real translations on the **first paint**
405
+ and **offline** — before the first CDN fetch — by embedding a build-time
406
+ snapshot:
407
+
408
+ ```tsx
409
+ import snapshot from "./sonenta-snapshot.json"; // { locale: { namespace: tree } }
410
+
411
+ <SonentaProvider {...config} initialBundles={snapshot}>
412
+ <App />
413
+ </SonentaProvider>;
414
+ ```
415
+
416
+ `initialBundles` is keyed `locale -> namespace -> tree` (the same shape as the
417
+ CDN JSON). It is primed synchronously, so `i18n.ready` is `true` on the very
418
+ first render when the snapshot covers the active locale's namespaces. On mount
419
+ the provider fetches the CDN and swaps in fresh values with **no flash**; if
420
+ that fetch fails (offline), the snapshot stays as last-known-good. Keys absent
421
+ from the snapshot do not fire "missing" reports until a real fetch confirms.
422
+
423
+ ### Generating the snapshot
424
+
425
+ - **CLI (recommended):** `verbumia snapshot` (from `@verbumia/cli`) fetches the
426
+ current published bundles and writes the JSON module.
427
+ - **Manual:** fetch each
428
+ `https://cdn.verbumia.ca/p/<project>/<version>/latest/<locale>/<ns>.json` and
429
+ assemble them into `{ [locale]: { [namespace]: <tree> } }`, then import it.
430
+
431
+ ## Surface variants
432
+
433
+ Render surface-specific copy (`desktop` / `mobile` / `tablet`) on top of your
434
+ normal locale resolution. A **base** bundle applies to every surface; a sparse
435
+ **surface overlay** (`{ns}.{surface}.json` on the CDN) overrides individual
436
+ keys for that surface only. `t()` returns the overlay value when present, else
437
+ the base — composing cleanly with locale fallback and plurals.
438
+
439
+ ```tsx
440
+ // Initial surface + reactive viewport detection (web):
441
+ <SonentaProvider {...config} surface="desktop" surfaceBreakpoints={true}>
442
+ <App />
443
+ </SonentaProvider>;
444
+ ```
445
+
446
+ - `surface` sets the initial surface (omit it to disable surface resolution
447
+ entirely — fully back-compatible).
448
+ - `surfaceBreakpoints={true}` enables reactive detection from the viewport on
449
+ web (the provider maps `window.innerWidth` → surface via `matchMedia` and
450
+ calls `setSurface` on boundary crossings). Pass custom thresholds with
451
+ `surfaceBreakpoints={{ mobile: 640, tablet: 1024 }}`.
452
+ - **React Native** (no `window`): set the initial `surface`, then drive changes
453
+ yourself from `useWindowDimensions`:
454
+
455
+ ```tsx
456
+ import { surfaceForWidth } from "@sonenta/react-i18next";
457
+ const { width } = useWindowDimensions();
458
+ useEffect(() => { i18n.setSurface(surfaceForWidth(width)); }, [width]);
459
+ ```
460
+
461
+ - Imperative: `i18n.surface`, `i18n.setSurface("mobile")`.
462
+ - **Asset variants** (minimal v1): an overlay key may carry
463
+ `{ "$value": "...", "$asset": { kind, ref } }`. `t(key)` returns `$value`;
464
+ read the companion ref with `i18n.asset(key, ns?)`.
465
+
466
+ Plurals work the same in overlays (single key + CLDR plural forms); an overlay
467
+ plural set fully replaces the base key's. Surface overlays are served from the
468
+ CDN; `env: "dev"` is base-only for now.
469
+
470
+ ## Recipes
471
+
472
+ ### Next.js (App Router)
473
+
474
+ Wrap the SDK in a Client Component and feed it env vars from `.env.local`:
475
+
476
+ ```tsx
477
+ // app/(sonenta)/i18n-client.tsx
478
+ "use client";
479
+ import { SonentaProvider } from "@sonenta/react-i18next";
480
+
481
+ export function I18nClient({ children }: { children: React.ReactNode }) {
482
+ return (
483
+ <SonentaProvider
484
+ token={process.env.NEXT_PUBLIC_VERBUMIA_TOKEN!}
485
+ projectUuid={process.env.NEXT_PUBLIC_VERBUMIA_PROJECT!}
486
+ defaultLocale="fr"
487
+ fallbackLng="en"
488
+ >
489
+ {children}
490
+ </SonentaProvider>
491
+ );
492
+ }
493
+ ```
494
+
495
+ The provider reads the bundle via the public CDN — no server-side state to
496
+ hydrate. SSR pre-renders the `defaultValue` and the client smoothly
497
+ upgrades after `i18n.ready` flips.
498
+
499
+ ### Storybook
500
+
501
+ ```ts
502
+ // .storybook/preview.tsx
503
+ import { SonentaProvider } from "@sonenta/react-i18next";
504
+
505
+ export const decorators = [
506
+ (Story) => (
507
+ <SonentaProvider
508
+ token="vrb_live_storybook.fake"
509
+ projectUuid="storybook"
510
+ defaultLocale="fr"
511
+ missingHandler="log"
512
+ transport={(batch) => action("missing-keys")(batch)}
513
+ >
514
+ <Story />
515
+ </SonentaProvider>
516
+ ),
517
+ ];
518
+ ```
519
+
520
+ ### Cypress
521
+
522
+ ```ts
523
+ cy.intercept("POST", "**/v1/missing", (req) => {
524
+ cy.task("captureMissing", req.body);
525
+ req.reply({ accepted: req.body.events.length, rejected: 0, items: [] });
526
+ });
527
+ ```
528
+
529
+ ---
530
+
531
+ ## Versioning
532
+
533
+ Semver. V1.x will keep the public API stable. Internal changes (bundle
534
+ fetcher, dedup heuristics) may shift in patch releases.
535
+
536
+ Breaking changes pre-V1 are flagged in [CONTRACT.md](./CONTRACT.md).
537
+
538
+ ## License
539
+
540
+ MIT — see [LICENSE](./LICENSE).