@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 +21 -0
- package/README.md +540 -0
- package/dist/index.cjs +1326 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +540 -0
- package/dist/index.d.ts +540 -0
- package/dist/index.js +1299 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
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
|
+
[](./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).
|