@owlmeans/client-i18n 0.1.18-rc.4 → 0.1.18-rc.41

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
@@ -12,7 +12,7 @@ React i18next adapter that loads translations registered via `@owlmeans/i18n` in
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- bun add @owlmeans/client-i18n
15
+ bun add @owlmeans/client-i18n@^0.1.18-rc.41
16
16
  ```
17
17
 
18
18
  ## Usage
@@ -68,7 +68,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
68
68
  your project's skill store (`.agents/skills/`):
69
69
 
70
70
  ```sh
71
- npx @owlmeans/agent-skills
71
+ npx @owlmeans/agent-skills@^0.1.18-rc.39
72
72
  ```
73
73
 
74
74
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/client-i18n",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.509Z",
4
+ "version": "0.1.18-rc.41",
5
+ "generatedAt": "2026-09-25T15:32:39.584Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: client-i18n
3
- description: How to use @owlmeans/client-i18n — React i18n context built on i18next. Auto-invoked when setting up translation in a React app, using translation hooks, or working with language switching.
3
+ description: How to use @owlmeans/client-i18n — React i18n context built on i18next. Auto-invoked when setting up translation in a React app, using translation hooks, working with language switching, or awaiting deferred language packs with prepareI18n.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,11 +8,11 @@ user-invocable: false
8
8
  # @owlmeans/client-i18n
9
9
 
10
10
  **Layer:** Client (React)
11
- **Install:** `"@owlmeans/client-i18n": "^0.1.18-rc.0"` in `dependencies`
11
+ **Install:** `"@owlmeans/client-i18n": "^0.1.18-rc.41"` in `dependencies`
12
12
 
13
13
  ## Purpose
14
14
 
15
- Wraps i18next + react-i18next, lazily loads registered bundles from `@owlmeans/i18n` storage into the i18next instance, and provides hooks for translating strings.
15
+ Wraps i18next + react-i18next, lazily loads registered bundles from `@owlmeans/i18n` storage into the i18next instance, awaits deferred language packs (`prepareI18n`, `setLanguage`), and provides hooks for translating strings.
16
16
 
17
17
  ## Key Exports
18
18
 
@@ -22,10 +22,44 @@ Wraps i18next + react-i18next, lazily loads registered bundles from `@owlmeans/i
22
22
  | `useI18nLib(resource, prefix?)` | Hook for library strings (ns=`'lib'`) |
23
23
  | `useI18nApp(resource?, prefix?)` | Hook for app strings (ns=resource name; defaults to `context.cfg.service`) |
24
24
  | `useI18n(resource, ns?, prefix?)` | Low-level hook with explicit ns — use when ns ≠ resource |
25
- | `useLanguage()` | `[currentLng, setLng]` — read and switch the active language |
25
+ | `useLanguage()` | `[currentLng, setLng]` — the i18next instance's language, and an async switch — see Language persistence |
26
+ | `prepareI18n`, `resolveInitialLanguage`, `setLanguage` | Re-exported from `./utils` — see below |
26
27
  | `composePrefix(parent?, child?)` | Canonical dot-join for prefix chaining; never call manually in panel code — used internally |
27
28
  | `I18nBaseProps` | `{ resource?, ns?, prefix?, suppress? }` |
28
29
  | `I18nProps` | `{ i18n?: I18nBaseProps }` |
30
+ | `I18nContextProps` | `{ config: ClientConfig }` — what the provider takes |
31
+
32
+ ## Subpath: `./utils`
33
+
34
+ The instance itself, for a host that has to configure i18next before the tree renders.
35
+
36
+ | Export | Description |
37
+ |---|---|
38
+ | `useI18nInstance(config)` | The i18next instance for this application, memoised for the mount |
39
+ | `getI18nInstance(config)` | The same instance outside React |
40
+ | `setLanguage(lng)` | Async: load `lng`'s pack (`loadI18nLanguage`), then persist (if the persistence guard allows) and switch — what `useLanguage`'s setter calls |
41
+ | `setLanguagePersistence(guard \| null)` | Install the guard that decides whether an explicit choice may be written to storage; `null` removes it. Call BEFORE `prepareI18n` |
42
+ | `persistLanguage()` | Write the explicit choice storage refused earlier, now that the guard allows; `false` when nothing waits or storage is still refused |
43
+ | `getExplicitLanguage()` | The language a person chose explicitly since this page loaded (stored or not), else `null` — what a host asks before applying a language that arrived some other way |
44
+ | `prepareI18n(config)` | Await the fallback language's loaders, then the initial language's; resolves to the language the instance will start in. Call once, before the first render |
45
+ | `resolveInitialLanguage(config)` | The persisted `owlmeans-lng` when it is in `supportedLngs`, else the browser's language (`preferredLanguageOf`), else `fallbackLng ?? defaultLng ?? DEFAULT_LNG` |
46
+ | `preferredLanguageOf(supportedLngs, preferred)` | Pure: the first preferred tag the config supports, exact or by base (`de-DE` → `de`), else `null` |
47
+
48
+ All of these live in `src/utils/instance.ts`; the package root re-exports `prepareI18n`,
49
+ `resolveInitialLanguage`, `setLanguage` and the three persistence functions above.
50
+
51
+ **There is exactly one instance per document**, created on first request and reused by every later
52
+ call whatever config is passed. So configuration is read once, at the first creation, and a plugin
53
+ is installed on the instance rather than passed to a second factory.
54
+
55
+ **The instance is initialized at creation with an explicit `lng`**, which is why the language is
56
+ resolved there and not by a plugin — by `resolveInitialLanguage` (or `prepareI18n`, which runs it):
57
+ the persisted choice (`owlmeans-lng`, when supported), else the browser's own language
58
+ (`navigator.languages` through `preferredLanguageOf` — a first visit from a German browser opens in
59
+ German), else `fallbackLng`. A language detector installed afterwards (`instance.use(detector)`,
60
+ which `@owlmeans/web-panel`'s `render` still does) is never consulted — harmless, but not what
61
+ detects. A detected language is not persisted, not even by the start-up switch that follows a
62
+ deferred pack's load; only an explicit choice (`setLanguage`) is.
29
63
 
30
64
  ## Setup (app root)
31
65
 
@@ -43,9 +77,55 @@ function App() {
43
77
 
44
78
  `clientConfig.i18n` is optional; it defaults to `SUPPORTED_LNGS` and `'en'` fallback.
45
79
 
80
+ **An app that defers language packs** (`addI18nLoader`, the `i18n` skill) calls
81
+ `await prepareI18n(config)` BEFORE its first render — never after:
82
+
83
+ ```ts
84
+ import { prepareI18n } from '@owlmeans/client-i18n'
85
+
86
+ await prepareI18n(context.cfg) // fallback pack first, then the persisted / browser / fallback one
87
+ render(context)
88
+ ```
89
+
90
+ This composes with an async boot because `@owlmeans/web-client`'s `render` (and so `renderApp` and
91
+ `@owlmeans/web-panel`'s `render`) checks `document.readyState` instead of waiting for a
92
+ `DOMContentLoaded` that already fired. `prepareI18n` rejects only when the FALLBACK pack fails; when
93
+ the initial language's pack fails it logs, resolves to the fallback and leaves the persisted choice
94
+ alone, so the next visit retries it rather than stranding the user. Without `prepareI18n`, the
95
+ instance starts in the fallback whenever the wanted language is not loaded yet and switches once its
96
+ loaders finish (a brief fallback flash) — but nothing awaits a deferred FALLBACK pack, whose slots
97
+ are then drained empty for the session.
98
+
46
99
  ## Language persistence
47
100
 
48
- The active language is persisted in `localStorage` under `owlmeans-lng` and restored on init.
101
+ The active language is persisted in `localStorage` under `owlmeans-lng` and restored on init — but
102
+ only when it is in `supportedLngs`, so a stored value that a later release dropped falls back to
103
+ the browser's language, then `fallbackLng`, instead of resolving nothing. Every storage and
104
+ `navigator` access is guarded, so a browser that refuses site data (or no browser) still renders.
105
+
106
+ `tests/instance.spec.ts` pins `preferredLanguageOf` (base matching, browser order, exact tag first,
107
+ `null` when nothing matches) and the persistence guard.
108
+
109
+ **Persistence is guarded, not assumed.** With no guard installed every explicit choice is written, as
110
+ always. An application that asks its visitors before remembering anything installs one
111
+ (`setLanguagePersistence(() => allowed)`; `@owlmeans/web-panel/consent`'s `installConsentLanguage()` is
112
+ the ready-made one for the cookie dialog's `functional` category — see the `consent` skill). While it
113
+ says no: `setLanguage` still switches the UI but writes nothing and remembers the refused choice
114
+ (`persistLanguage()` writes it when storage becomes allowed), and a value already in storage counts
115
+ as ABSENT at start-up (`resolveInitialLanguage`), so a language nobody was allowed to remember is not
116
+ used. Installing a guard also resets what this page has chosen so far — install it before anything
117
+ calls `setLanguage`.
118
+
119
+ `setLanguage(lng)` is async: it awaits `loadI18nLanguage(lng)`, then persists and calls i18next's
120
+ `changeLanguage` — before the instance exists it records the language the instance will start in
121
+ instead. The LAST call wins: an earlier call whose load finishes later does nothing. A failed load
122
+ rejects, and nothing is persisted or switched.
123
+
124
+ `useLanguage()` returns `i18n.language` from `useTranslation()`, not local state, so every caller
125
+ re-renders on i18next's `languageChanged` and shows the same language whoever switched it. Its
126
+ setter returns a promise that never rejects (a failure is logged): the value changes only once the
127
+ pack has loaded and i18next switched, so a controlled `<select value={lng}>` stays on the current
128
+ language meanwhile, and stays there when the load fails.
49
129
 
50
130
  ```tsx
51
131
  function LangSwitch() {
@@ -96,16 +176,24 @@ t('title') // → did:wallet.createKey.title
96
176
 
97
177
  ## App-level override of library strings
98
178
 
99
- Import the library's i18n registration **and** register your own version at App tier:
179
+ Import the library's i18n registration **and** register your own version at App tier **in the
180
+ library's namespace**:
100
181
  ```typescript
101
- import '@owlmeans/error/i18n' // library en strings
102
- import { addI18nApp } from '@owlmeans/i18n'
103
- import myErrors from './i18n/en.json' // with { type: 'json' }
182
+ import '@owlmeans/error' // the library bundle, registered by side effect
183
+ import { addI18nApp, LIB_NAMESPACE } from '@owlmeans/i18n'
184
+ import myErrors from './i18n/en.json' with { type: 'json' }
104
185
 
105
- // App tier wins — these override the library's 'errors' strings
106
- addI18nApp('en', 'errors', myErrors)
186
+ // App tier merges last within (ns, resource, lng) — these override the library's 'errors' strings
187
+ addI18nApp('en', 'errors', myErrors, { ns: LIB_NAMESPACE })
107
188
  ```
108
189
 
190
+ `{ ns: LIB_NAMESPACE }` is what makes it an override. `addI18nApp` defaults the namespace to the
191
+ **resource** name, so without it the bundle lands in namespace `errors` while `useI18nLib('errors')`
192
+ reads namespace `lib` — nothing errors, nothing merges, and the library strings keep rendering.
193
+
194
+ A package registers its strings from its own root: `src/i18n.ts` is re-exported by `src/index.ts`,
195
+ so importing the package is what loads them. No package publishes an `./i18n` subpath.
196
+
109
197
  ## Resource JSON format
110
198
 
111
199
  The shared instance runs i18next's **v4** JSON format — `compatibilityJSON` is not set and must
@@ -120,5 +208,6 @@ misbehaves at runtime.
120
208
 
121
209
  ## Depends On
122
210
 
123
- `@owlmeans/i18n`, `@owlmeans/client-context`, `i18next` (>= 26.2), `react-i18next` (>= 17),
124
- `react` (peer)
211
+ `@owlmeans/i18n`, `@owlmeans/client`, `@owlmeans/client-context`, `i18next` (>= 26.2),
212
+ `react-i18next` (>= 17), `react` (the one peer). `@owlmeans/client` is what `useI18nApp` reads the
213
+ current context from, to default the resource to `context.cfg.service`.
@@ -9,5 +9,11 @@ export declare const composePrefix: (parent?: string, child?: string) => string;
9
9
  export declare const useI18n: (resource: string, ns?: string, prefix?: string) => TFunction;
10
10
  export declare const useI18nLib: (libName: string, prefix?: string) => TFunction;
11
11
  export declare const useI18nApp: (appName?: string, prefix?: string) => TFunction;
12
- export declare const useLanguage: () => [string, (lng: string) => void];
12
+ /**
13
+ * `[currentLng, setLng]`. The current language is read from the i18next instance itself, whose
14
+ * `languageChanged` event re-renders every subscriber, so all callers agree whoever switched it.
15
+ * The setter loads the language's pack first; a failed switch is logged and leaves the language
16
+ * unchanged.
17
+ */
18
+ export declare const useLanguage: () => [string, (lng: string) => Promise<void>];
13
19
  //# sourceMappingURL=context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAC/B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAElD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AAKxC,eAAO,MAAM,WAAW,EAAE,EAAE,CAAC,gBAAgB,CAI3C,CAAA;AAEF,kGAAkG;AAClG,eAAO,MAAM,aAAa,YAAa,MAAM,UAAU,MAAM,KAAG,MAK/D,CAAA;AAyCD;4FAC4F;AAC5F,eAAO,MAAM,OAAO,aAAc,MAAM,OAAO,MAAM,WAAW,MAAM,KAAG,SAExE,CAAA;AAED,eAAO,MAAM,UAAU,YAAa,MAAM,WAAW,MAAM,KAAG,SAE7D,CAAA;AAED,eAAO,MAAM,UAAU,aAAc,MAAM,WAAW,MAAM,KAAG,SAK9D,CAAA;AAED,eAAO,MAAM,WAAW,QAAO,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAU5D,CAAA"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,OAAO,CAAA;AAC/B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAElD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AAKxC,eAAO,MAAM,WAAW,EAAE,EAAE,CAAC,gBAAgB,CAI3C,CAAA;AAEF,kGAAkG;AAClG,eAAO,MAAM,aAAa,YAAa,MAAM,UAAU,MAAM,KAAG,MAK/D,CAAA;AAyCD;4FAC4F;AAC5F,eAAO,MAAM,OAAO,aAAc,MAAM,OAAO,MAAM,WAAW,MAAM,KAAG,SAExE,CAAA;AAED,eAAO,MAAM,UAAU,YAAa,MAAM,WAAW,MAAM,KAAG,SAE7D,CAAA;AAED,eAAO,MAAM,UAAU,aAAc,MAAM,WAAW,MAAM,KAAG,SAK9D,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,QAAO,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAYrE,CAAA"}
package/build/context.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { memo, useCallback, useState } from 'react';
2
+ import { memo, useCallback } from 'react';
3
3
  import { I18nextProvider, useTranslation } from 'react-i18next';
4
4
  import { setLanguage, useI18nInstance } from './utils/instance.js';
5
5
  import { DEFAULT_LNG, DEFAULT_NAMESPACE, initI18nResource, LIB_NAMESPACE } from '@owlmeans/i18n';
@@ -57,13 +57,22 @@ export const useI18nApp = (appName, prefix) => {
57
57
  const resolvedName = appName ?? context.cfg.service;
58
58
  return useI18nResource(resolvedName, resolvedName, prefix);
59
59
  };
60
+ /**
61
+ * `[currentLng, setLng]`. The current language is read from the i18next instance itself, whose
62
+ * `languageChanged` event re-renders every subscriber, so all callers agree whoever switched it.
63
+ * The setter loads the language's pack first; a failed switch is logged and leaves the language
64
+ * unchanged.
65
+ */
60
66
  export const useLanguage = () => {
61
67
  const { i18n } = useTranslation();
62
- const [lng, setLng] = useState(i18n.language);
63
- const changeLng = useCallback((next) => {
64
- setLanguage(next);
65
- setLng(next);
68
+ const changeLng = useCallback(async (next) => {
69
+ try {
70
+ await setLanguage(next);
71
+ }
72
+ catch (error) {
73
+ console.error('[i18n] language switch failed', error);
74
+ }
66
75
  }, []);
67
- return [lng, changeLng];
76
+ return [i18n.language, changeLng];
68
77
  };
69
78
  //# sourceMappingURL=context.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.tsx"],"names":[],"mappings":"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"}
package/build/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './types.js';
2
2
  export { I18nContext, composePrefix, useI18n, useI18nLib, useI18nApp, useLanguage } from './context.js';
3
+ export { prepareI18n, resolveInitialLanguage, setLanguage, setLanguagePersistence, persistLanguage, getExplicitLanguage, } from './utils/instance.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AACvG,OAAO,EACL,WAAW,EAAE,sBAAsB,EAAE,WAAW,EAAE,sBAAsB,EAAE,eAAe,EAAE,mBAAmB,GAC/G,MAAM,qBAAqB,CAAA"}
package/build/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './types.js';
2
2
  export { I18nContext, composePrefix, useI18n, useI18nLib, useI18nApp, useLanguage } from './context.js';
3
+ export { prepareI18n, resolveInitialLanguage, setLanguage, setLanguagePersistence, persistLanguage, getExplicitLanguage, } from './utils/instance.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AACvG,OAAO,EACL,WAAW,EAAE,sBAAsB,EAAE,WAAW,EAAE,sBAAsB,EAAE,eAAe,EAAE,mBAAmB,GAC/G,MAAM,qBAAqB,CAAA"}
@@ -1,6 +1,51 @@
1
1
  import type { i18n } from 'i18next';
2
2
  import type { ClientConfig } from '@owlmeans/client-context';
3
+ /**
4
+ * The language a person chose explicitly since this page loaded (`setLanguage`), or `null`. What a
5
+ * host asks before it applies a language that arrived some other way — a link — which must never
6
+ * override what the person just picked.
7
+ */
8
+ export declare const getExplicitLanguage: () => string | null;
9
+ /**
10
+ * Install (or, with `null`, remove) the guard that decides whether a language choice may be
11
+ * remembered. Call it BEFORE `prepareI18n`: the initial language is resolved from storage, and a
12
+ * guard that says no makes a stored value count as absent.
13
+ */
14
+ export declare const setLanguagePersistence: (guard: (() => boolean) | null) => void;
15
+ /**
16
+ * Write the explicit choice storage refused earlier, now that it may be — call it when the guard's
17
+ * answer turns to yes (the visitor granted functional storage). `false` when there was nothing
18
+ * waiting, storage is still refused, or the write failed.
19
+ */
20
+ export declare const persistLanguage: () => boolean;
3
21
  export declare const useI18nInstance: (config: ClientConfig) => i18n;
4
22
  export declare const getI18nInstance: (config: ClientConfig) => i18n;
5
- export declare const setLanguage: (lng: string) => void;
23
+ /**
24
+ * Load `lng`'s registered loaders, then persist it and switch the instance to it. Race-safe:
25
+ * when several calls overlap, the latest one wins whatever order their loads finish in.
26
+ * Rejects when the language's pack fails to load; nothing is persisted or switched then.
27
+ */
28
+ export declare const setLanguage: (lng: string) => Promise<void>;
29
+ /**
30
+ * Await the app's fallback language and its initial one (`resolveInitialLanguage`) before the app
31
+ * renders, so the first paint is already in the right language instead of flashing the
32
+ * fallback and then switching. Call it BEFORE `render(...)`. Resolves to the language the
33
+ * instance will start in: the fallback when the initial language's pack fails to load — the
34
+ * persisted choice is kept then, so the next visit retries it. Rejects only when the fallback
35
+ * language's own pack fails.
36
+ */
37
+ export declare const prepareI18n: (config: ClientConfig) => Promise<string>;
38
+ /**
39
+ * The language the app starts in: the one persisted under `owlmeans-lng` when it is still in
40
+ * `supportedLngs` (a person's explicit choice); else, on a first visit, the browser's own language
41
+ * (`navigator.languages` through `preferredLanguageOf` — a German browser opens in German); else
42
+ * `fallbackLng ?? defaultLng ?? DEFAULT_LNG`. The instance is initialized with this explicit `lng`,
43
+ * so a detector plugin installed afterwards (`instance.use(detector)`) is never consulted.
44
+ */
45
+ export declare const resolveInitialLanguage: (config: ClientConfig) => string;
46
+ /**
47
+ * The first of the browser's preferred languages the application supports — exact (`pt-BR`) or by
48
+ * its base (`de-DE` → `de`) — or `null` when none is. Pure, so a host (or a test) can pass the list.
49
+ */
50
+ export declare const preferredLanguageOf: (supportedLngs: readonly string[], preferred: readonly (string | null | undefined)[]) => string | null;
6
51
  //# sourceMappingURL=instance.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAEnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAO5D,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAItD,CAAA;AAED,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAC3B,CAAA;AAE5B,eAAO,MAAM,WAAW,QAAS,MAAM,KAAG,IAMzC,CAAA"}
1
+ {"version":3,"file":"instance.d.ts","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAA;AAEnC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AA4B5D;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,QAAO,MAAM,GAAG,IAAsB,CAAA;AAItE;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,UAAW,CAAC,MAAM,OAAO,CAAC,GAAG,IAAI,KAAG,IAKtE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAAO,OAYlC,CAAA;AAED,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAItD,CAAA;AAED,eAAO,MAAM,eAAe,WAAY,YAAY,KAAG,IAC3B,CAAA;AAE5B;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAS,MAAM,KAAG,OAAO,CAAC,IAAI,CAA8B,CAAA;AA+BpF;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,WAAkB,YAAY,KAAG,OAAO,CAAC,MAAM,CAatE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,WAAY,YAAY,KAAG,MAO7D,CAAA;AAgBD;;;GAGG;AACH,eAAO,MAAM,mBAAmB,kBACf,SAAS,MAAM,EAAE,aAAa,SAAS,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,KAClF,MAAM,GAAG,IAUX,CAAA"}
@@ -1,24 +1,143 @@
1
1
  import { createInstance } from 'i18next';
2
2
  import { initReactI18next } from 'react-i18next';
3
3
  import { useMemo } from 'react';
4
- import { DEFAULT_LNG, DEFAULT_NAMESPACE, SUPPORTED_LNGS } from '@owlmeans/i18n';
4
+ import { DEFAULT_LNG, DEFAULT_NAMESPACE, SUPPORTED_LNGS, isI18nLanguageLoaded, loadI18nLanguage } from '@owlmeans/i18n';
5
5
  const LNG_STORAGE_KEY = 'owlmeans-lng';
6
6
  let i18nInstance = null;
7
+ /** The language `prepareI18n` loaded; the instance starts in it when created afterwards. */
8
+ let preparedLng;
9
+ /** The language of the latest `setLanguage` call — an earlier call that finishes later yields to it. */
10
+ let requestedLng;
11
+ /**
12
+ * Whether an explicit language choice may be written to storage right now. Unset, storage is always
13
+ * allowed — an application with no notion of consent behaves exactly as before. A host that asks its
14
+ * visitors (`@owlmeans/web-panel/consent`'s `installConsentLanguage`) installs one that answers
15
+ * "has the visitor allowed functional storage?", so a language is not remembered on a device nobody
16
+ * asked about.
17
+ */
18
+ let persistenceGuard = null;
19
+ /** The latest explicit choice that storage refused; written the moment storage is allowed. */
20
+ let refusedChoice = null;
21
+ /** The latest language a person chose explicitly in this page's life, stored or not. */
22
+ let explicitChoice = null;
23
+ /**
24
+ * The language a person chose explicitly since this page loaded (`setLanguage`), or `null`. What a
25
+ * host asks before it applies a language that arrived some other way — a link — which must never
26
+ * override what the person just picked.
27
+ */
28
+ export const getExplicitLanguage = () => explicitChoice;
29
+ const persistenceAllowed = () => persistenceGuard == null || persistenceGuard();
30
+ /**
31
+ * Install (or, with `null`, remove) the guard that decides whether a language choice may be
32
+ * remembered. Call it BEFORE `prepareI18n`: the initial language is resolved from storage, and a
33
+ * guard that says no makes a stored value count as absent.
34
+ */
35
+ export const setLanguagePersistence = (guard) => {
36
+ persistenceGuard = guard;
37
+ // A new guard starts a new account of what this page's visitor has chosen and what storage refused.
38
+ refusedChoice = null;
39
+ explicitChoice = null;
40
+ };
41
+ /**
42
+ * Write the explicit choice storage refused earlier, now that it may be — call it when the guard's
43
+ * answer turns to yes (the visitor granted functional storage). `false` when there was nothing
44
+ * waiting, storage is still refused, or the write failed.
45
+ */
46
+ export const persistLanguage = () => {
47
+ if (refusedChoice == null || !persistenceAllowed()) {
48
+ return false;
49
+ }
50
+ try {
51
+ localStorage.setItem(LNG_STORAGE_KEY, refusedChoice);
52
+ refusedChoice = null;
53
+ return true;
54
+ }
55
+ catch (_) {
56
+ return false;
57
+ }
58
+ };
7
59
  export const useI18nInstance = (config) => {
8
60
  const instance = useMemo(() => createI18nInstance(config), []);
9
61
  return instance;
10
62
  };
11
63
  export const getI18nInstance = (config) => createI18nInstance(config);
12
- export const setLanguage = (lng) => {
13
- if (i18nInstance == null)
64
+ /**
65
+ * Load `lng`'s registered loaders, then persist it and switch the instance to it. Race-safe:
66
+ * when several calls overlap, the latest one wins whatever order their loads finish in.
67
+ * Rejects when the language's pack fails to load; nothing is persisted or switched then.
68
+ */
69
+ export const setLanguage = (lng) => switchLanguage(lng, true);
70
+ /**
71
+ * `setLanguage`'s body. `persist` is false only for the start-up switch to a language nobody chose
72
+ * (a browser-detected one): only an explicit choice is ever written to `owlmeans-lng`.
73
+ */
74
+ const switchLanguage = async (lng, persist) => {
75
+ requestedLng = lng;
76
+ await loadI18nLanguage(lng);
77
+ if (requestedLng !== lng) {
14
78
  return;
79
+ }
80
+ if (persist) {
81
+ explicitChoice = lng;
82
+ if (persistenceAllowed()) {
83
+ refusedChoice = null;
84
+ try {
85
+ localStorage.setItem(LNG_STORAGE_KEY, lng);
86
+ }
87
+ catch (_) { /* noop in non-browser environments */ }
88
+ }
89
+ else {
90
+ // Not remembered — but not forgotten either: the visitor may allow it a moment later.
91
+ refusedChoice = lng;
92
+ }
93
+ }
94
+ if (i18nInstance == null) {
95
+ preparedLng = lng;
96
+ return;
97
+ }
98
+ await i18nInstance.changeLanguage(lng);
99
+ };
100
+ /**
101
+ * Await the app's fallback language and its initial one (`resolveInitialLanguage`) before the app
102
+ * renders, so the first paint is already in the right language instead of flashing the
103
+ * fallback and then switching. Call it BEFORE `render(...)`. Resolves to the language the
104
+ * instance will start in: the fallback when the initial language's pack fails to load — the
105
+ * persisted choice is kept then, so the next visit retries it. Rejects only when the fallback
106
+ * language's own pack fails.
107
+ */
108
+ export const prepareI18n = async (config) => {
109
+ const fallback = resolveFallbackLanguage(config);
110
+ await loadI18nLanguage(fallback);
111
+ const lng = resolveInitialLanguage(config);
15
112
  try {
16
- localStorage.setItem(LNG_STORAGE_KEY, lng);
113
+ await loadI18nLanguage(lng);
114
+ preparedLng = lng;
17
115
  }
18
- catch (_) { /* noop in non-browser environments */ }
19
- i18nInstance.changeLanguage(lng);
116
+ catch (error) {
117
+ console.error(`[i18n] failed to load pack for "${lng}"`, error);
118
+ preparedLng = fallback;
119
+ }
120
+ return preparedLng;
121
+ };
122
+ /**
123
+ * The language the app starts in: the one persisted under `owlmeans-lng` when it is still in
124
+ * `supportedLngs` (a person's explicit choice); else, on a first visit, the browser's own language
125
+ * (`navigator.languages` through `preferredLanguageOf` — a German browser opens in German); else
126
+ * `fallbackLng ?? defaultLng ?? DEFAULT_LNG`. The instance is initialized with this explicit `lng`,
127
+ * so a detector plugin installed afterwards (`instance.use(detector)`) is never consulted.
128
+ */
129
+ export const resolveInitialLanguage = (config) => {
130
+ const supportedLngs = config.i18n?.supportedLngs ?? [...SUPPORTED_LNGS];
131
+ const persistedLng = getPersistedLanguage();
132
+ return persistedLng != null && supportedLngs.includes(persistedLng)
133
+ ? persistedLng
134
+ : preferredLanguageOf(supportedLngs, browserLanguages()) ?? resolveFallbackLanguage(config);
20
135
  };
136
+ const resolveFallbackLanguage = (config) => config.i18n?.fallbackLng ?? config.i18n?.defaultLng ?? DEFAULT_LNG;
21
137
  const getPersistedLanguage = () => {
138
+ if (!persistenceAllowed()) {
139
+ return null;
140
+ }
22
141
  try {
23
142
  return localStorage.getItem(LNG_STORAGE_KEY);
24
143
  }
@@ -26,16 +145,46 @@ const getPersistedLanguage = () => {
26
145
  return null;
27
146
  }
28
147
  };
148
+ /**
149
+ * The first of the browser's preferred languages the application supports — exact (`pt-BR`) or by
150
+ * its base (`de-DE` → `de`) — or `null` when none is. Pure, so a host (or a test) can pass the list.
151
+ */
152
+ export const preferredLanguageOf = (supportedLngs, preferred) => {
153
+ const supported = new Map(supportedLngs.map(lng => [lng.toLowerCase(), lng]));
154
+ for (const candidate of preferred) {
155
+ const tag = candidate?.trim().toLowerCase() ?? '';
156
+ if (tag === '')
157
+ continue;
158
+ const match = supported.get(tag) ?? supported.get(tag.split(/[-_]/)[0]);
159
+ if (match != null)
160
+ return match;
161
+ }
162
+ return null;
163
+ };
164
+ /** The browser's preferred languages, in order; `[]` outside a browser. */
165
+ const browserLanguages = () => {
166
+ try {
167
+ if (typeof navigator === 'undefined')
168
+ return [];
169
+ return navigator.languages != null && navigator.languages.length > 0
170
+ ? navigator.languages
171
+ : navigator.language != null ? [navigator.language] : [];
172
+ }
173
+ catch (_) {
174
+ return [];
175
+ }
176
+ };
29
177
  const createI18nInstance = (config) => {
30
178
  if (i18nInstance != null) {
31
179
  return i18nInstance;
32
180
  }
33
- const fallbackLng = config.i18n?.fallbackLng ?? config.i18n?.defaultLng ?? DEFAULT_LNG;
181
+ const fallbackLng = resolveFallbackLanguage(config);
34
182
  const supportedLngs = config.i18n?.supportedLngs ?? [...SUPPORTED_LNGS];
35
- const persistedLng = getPersistedLanguage();
36
- const lng = persistedLng != null && supportedLngs.includes(persistedLng)
37
- ? persistedLng
38
- : fallbackLng;
183
+ const wantedLng = preparedLng ?? resolveInitialLanguage(config);
184
+ // Safety net for a host that did not await `prepareI18n`: start in the fallback and switch once
185
+ // the wanted language's loaders finish — a brief fallback flash instead of a half-loaded language.
186
+ const loaded = isI18nLanguageLoaded(wantedLng);
187
+ const lng = loaded ? wantedLng : fallbackLng;
39
188
  const instance = createInstance({
40
189
  // No `compatibilityJSON` — i18next >= 26 accepts only the v4 JSON format
41
190
  // (Intl.PluralRules suffixes `_one`/`_other`, not the v3 `_plural`/`_0`/`_1`).
@@ -48,6 +197,13 @@ const createI18nInstance = (config) => {
48
197
  debug: config.debug?.all ?? config.debug?.i18n ?? false,
49
198
  });
50
199
  instance.use(initReactI18next).init();
51
- return i18nInstance = instance;
200
+ i18nInstance = instance;
201
+ if (!loaded) {
202
+ // Not persisted: `wantedLng` may be a browser-detected language nobody chose.
203
+ switchLanguage(wantedLng, false).catch((error) => {
204
+ console.error(`[i18n] failed to load pack for "${wantedLng}"`, error);
205
+ });
206
+ }
207
+ return instance;
52
208
  };
53
209
  //# sourceMappingURL=instance.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"instance.js","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AAEhD,OAAO,EAAE,OAAO,EAAE,MAAM,OAAO,CAAA;AAE/B,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAE/E,MAAM,eAAe,GAAG,cAAc,CAAA;AAEtC,IAAI,YAAY,GAAgB,IAAI,CAAA;AAEpC,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,MAAoB,EAAQ,EAAE;IAC5D,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,CAAA;IAE9D,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,MAAoB,EAAQ,EAAE,CAC5D,kBAAkB,CAAC,MAAM,CAAC,CAAA;AAE5B,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,GAAW,EAAQ,EAAE;IAC/C,IAAI,YAAY,IAAI,IAAI;QAAE,OAAM;IAChC,IAAI,CAAC;QACH,YAAY,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,CAAA;IAC5C,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAC,sCAAsC,CAAC,CAAC;IACtD,YAAY,CAAC,cAAc,CAAC,GAAG,CAAC,CAAA;AAClC,CAAC,CAAA;AAED,MAAM,oBAAoB,GAAG,GAAkB,EAAE;IAC/C,IAAI,CAAC;QACH,OAAO,YAAY,CAAC,OAAO,CAAC,eAAe,CAAC,CAAA;IAC9C,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC,CAAA;AAED,MAAM,kBAAkB,GAAG,CAAC,MAAoB,EAAQ,EAAE;IACxD,IAAI,YAAY,IAAI,IAAI,EAAE,CAAC;QACzB,OAAO,YAAY,CAAA;IACrB,CAAC;IAED,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,EAAE,WAAW,IAAI,MAAM,CAAC,IAAI,EAAE,UAAU,IAAI,WAAW,CAAA;IACtF,MAAM,aAAa,GAAG,MAAM,CAAC,IAAI,EAAE,aAAa,IAAI,CAAC,GAAG,cAAc,CAAC,CAAA;IACvE,MAAM,YAAY,GAAG,oBAAoB,EAAE,CAAA;IAC3C,MAAM,GAAG,GAAG,YAAY,IAAI,IAAI,IAAI,aAAa,CAAC,QAAQ,CAAC,YAAY,CAAC;QACtE,CAAC,CAAC,YAAY;QACd,CAAC,CAAC,WAAW,CAAA;IAEf,MAAM,QAAQ,GAAG,cAAc,CAAC;QAC9B,yEAAyE;QACzE,+EAA+E;QAC/E,+EAA+E;QAC/E,oDAAoD;QACpD,SAAS,EAAE,MAAM,CAAC,IAAI,EAAE,SAAS,IAAI,iBAAiB;QACtD,WAAW;QACX,GAAG;QACH,aAAa;QACb,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,GAAG,IAAI,MAAM,CAAC,KAAK,EAAE,IAAI,IAAI,KAAK;KACxD,CAAC,CAAA;IAEF,QAAQ,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,IAAI,EAAE,CAAA;IAErC,OAAO,YAAY,GAAG,QAAQ,CAAA;AAChC,CAAC,CAAA"}
1
+ {"version":3,"file":"instance.js","sourceRoot":"","sources":["../../src/utils/instance.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/client-i18n",
3
- "version": "0.1.18-rc.4",
3
+ "version": "0.1.18-rc.41",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -37,9 +37,9 @@
37
37
  "typescript": "^7.0.2"
38
38
  },
39
39
  "dependencies": {
40
- "@owlmeans/client": "^0.1.18-rc.4",
41
- "@owlmeans/client-context": "^0.1.18-rc.4",
42
- "@owlmeans/i18n": "^0.1.18-rc.4",
40
+ "@owlmeans/client": "^0.1.18-rc.42",
41
+ "@owlmeans/client-context": "^0.1.18-rc.39",
42
+ "@owlmeans/i18n": "^0.1.18-rc.37",
43
43
  "i18next": "^26.2.0",
44
44
  "react-i18next": "^17.0.11"
45
45
  },
package/src/context.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useState } from 'react'
1
+ import { memo, useCallback } from 'react'
2
2
  import type { FC } from 'react'
3
3
  import type { I18nContextProps } from './types.js'
4
4
  import { I18nextProvider, useTranslation } from 'react-i18next'
@@ -77,14 +77,22 @@ export const useI18nApp = (appName?: string, prefix?: string): TFunction => {
77
77
  return useI18nResource(resolvedName, resolvedName, prefix)
78
78
  }
79
79
 
80
- export const useLanguage = (): [string, (lng: string) => void] => {
80
+ /**
81
+ * `[currentLng, setLng]`. The current language is read from the i18next instance itself, whose
82
+ * `languageChanged` event re-renders every subscriber, so all callers agree whoever switched it.
83
+ * The setter loads the language's pack first; a failed switch is logged and leaves the language
84
+ * unchanged.
85
+ */
86
+ export const useLanguage = (): [string, (lng: string) => Promise<void>] => {
81
87
  const { i18n } = useTranslation()
82
- const [lng, setLng] = useState(i18n.language)
83
88
 
84
- const changeLng = useCallback((next: string) => {
85
- setLanguage(next)
86
- setLng(next)
89
+ const changeLng = useCallback(async (next: string) => {
90
+ try {
91
+ await setLanguage(next)
92
+ } catch (error) {
93
+ console.error('[i18n] language switch failed', error)
94
+ }
87
95
  }, [])
88
96
 
89
- return [lng, changeLng]
97
+ return [i18n.language, changeLng]
90
98
  }
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
1
 
2
2
  export * from './types.js'
3
3
  export { I18nContext, composePrefix, useI18n, useI18nLib, useI18nApp, useLanguage } from './context.js'
4
+ export {
5
+ prepareI18n, resolveInitialLanguage, setLanguage, setLanguagePersistence, persistLanguage, getExplicitLanguage,
6
+ } from './utils/instance.js'
@@ -4,12 +4,73 @@ import { initReactI18next } from 'react-i18next'
4
4
  import type { i18n } from 'i18next'
5
5
  import { useMemo } from 'react'
6
6
  import type { ClientConfig } from '@owlmeans/client-context'
7
- import { DEFAULT_LNG, DEFAULT_NAMESPACE, SUPPORTED_LNGS } from '@owlmeans/i18n'
7
+ import { DEFAULT_LNG, DEFAULT_NAMESPACE, SUPPORTED_LNGS, isI18nLanguageLoaded, loadI18nLanguage } from '@owlmeans/i18n'
8
8
 
9
9
  const LNG_STORAGE_KEY = 'owlmeans-lng'
10
10
 
11
11
  let i18nInstance: i18n | null = null
12
12
 
13
+ /** The language `prepareI18n` loaded; the instance starts in it when created afterwards. */
14
+ let preparedLng: string | undefined
15
+
16
+ /** The language of the latest `setLanguage` call — an earlier call that finishes later yields to it. */
17
+ let requestedLng: string | undefined
18
+
19
+ /**
20
+ * Whether an explicit language choice may be written to storage right now. Unset, storage is always
21
+ * allowed — an application with no notion of consent behaves exactly as before. A host that asks its
22
+ * visitors (`@owlmeans/web-panel/consent`'s `installConsentLanguage`) installs one that answers
23
+ * "has the visitor allowed functional storage?", so a language is not remembered on a device nobody
24
+ * asked about.
25
+ */
26
+ let persistenceGuard: (() => boolean) | null = null
27
+
28
+ /** The latest explicit choice that storage refused; written the moment storage is allowed. */
29
+ let refusedChoice: string | null = null
30
+
31
+ /** The latest language a person chose explicitly in this page's life, stored or not. */
32
+ let explicitChoice: string | null = null
33
+
34
+ /**
35
+ * The language a person chose explicitly since this page loaded (`setLanguage`), or `null`. What a
36
+ * host asks before it applies a language that arrived some other way — a link — which must never
37
+ * override what the person just picked.
38
+ */
39
+ export const getExplicitLanguage = (): string | null => explicitChoice
40
+
41
+ const persistenceAllowed = (): boolean => persistenceGuard == null || persistenceGuard()
42
+
43
+ /**
44
+ * Install (or, with `null`, remove) the guard that decides whether a language choice may be
45
+ * remembered. Call it BEFORE `prepareI18n`: the initial language is resolved from storage, and a
46
+ * guard that says no makes a stored value count as absent.
47
+ */
48
+ export const setLanguagePersistence = (guard: (() => boolean) | null): void => {
49
+ persistenceGuard = guard
50
+ // A new guard starts a new account of what this page's visitor has chosen and what storage refused.
51
+ refusedChoice = null
52
+ explicitChoice = null
53
+ }
54
+
55
+ /**
56
+ * Write the explicit choice storage refused earlier, now that it may be — call it when the guard's
57
+ * answer turns to yes (the visitor granted functional storage). `false` when there was nothing
58
+ * waiting, storage is still refused, or the write failed.
59
+ */
60
+ export const persistLanguage = (): boolean => {
61
+ if (refusedChoice == null || !persistenceAllowed()) {
62
+ return false
63
+ }
64
+ try {
65
+ localStorage.setItem(LNG_STORAGE_KEY, refusedChoice)
66
+ refusedChoice = null
67
+
68
+ return true
69
+ } catch (_) {
70
+ return false
71
+ }
72
+ }
73
+
13
74
  export const useI18nInstance = (config: ClientConfig): i18n => {
14
75
  const instance = useMemo(() => createI18nInstance(config), [])
15
76
 
@@ -19,15 +80,88 @@ export const useI18nInstance = (config: ClientConfig): i18n => {
19
80
  export const getI18nInstance = (config: ClientConfig): i18n =>
20
81
  createI18nInstance(config)
21
82
 
22
- export const setLanguage = (lng: string): void => {
23
- if (i18nInstance == null) return
83
+ /**
84
+ * Load `lng`'s registered loaders, then persist it and switch the instance to it. Race-safe:
85
+ * when several calls overlap, the latest one wins whatever order their loads finish in.
86
+ * Rejects when the language's pack fails to load; nothing is persisted or switched then.
87
+ */
88
+ export const setLanguage = (lng: string): Promise<void> => switchLanguage(lng, true)
89
+
90
+ /**
91
+ * `setLanguage`'s body. `persist` is false only for the start-up switch to a language nobody chose
92
+ * (a browser-detected one): only an explicit choice is ever written to `owlmeans-lng`.
93
+ */
94
+ const switchLanguage = async (lng: string, persist: boolean): Promise<void> => {
95
+ requestedLng = lng
96
+ await loadI18nLanguage(lng)
97
+ if (requestedLng !== lng) {
98
+ return
99
+ }
100
+ if (persist) {
101
+ explicitChoice = lng
102
+ if (persistenceAllowed()) {
103
+ refusedChoice = null
104
+ try {
105
+ localStorage.setItem(LNG_STORAGE_KEY, lng)
106
+ } catch (_) { /* noop in non-browser environments */ }
107
+ } else {
108
+ // Not remembered — but not forgotten either: the visitor may allow it a moment later.
109
+ refusedChoice = lng
110
+ }
111
+ }
112
+ if (i18nInstance == null) {
113
+ preparedLng = lng
114
+ return
115
+ }
116
+ await i18nInstance.changeLanguage(lng)
117
+ }
118
+
119
+ /**
120
+ * Await the app's fallback language and its initial one (`resolveInitialLanguage`) before the app
121
+ * renders, so the first paint is already in the right language instead of flashing the
122
+ * fallback and then switching. Call it BEFORE `render(...)`. Resolves to the language the
123
+ * instance will start in: the fallback when the initial language's pack fails to load — the
124
+ * persisted choice is kept then, so the next visit retries it. Rejects only when the fallback
125
+ * language's own pack fails.
126
+ */
127
+ export const prepareI18n = async (config: ClientConfig): Promise<string> => {
128
+ const fallback = resolveFallbackLanguage(config)
129
+ await loadI18nLanguage(fallback)
130
+ const lng = resolveInitialLanguage(config)
24
131
  try {
25
- localStorage.setItem(LNG_STORAGE_KEY, lng)
26
- } catch (_) { /* noop in non-browser environments */ }
27
- i18nInstance.changeLanguage(lng)
132
+ await loadI18nLanguage(lng)
133
+ preparedLng = lng
134
+ } catch (error) {
135
+ console.error(`[i18n] failed to load pack for "${lng}"`, error)
136
+ preparedLng = fallback
137
+ }
138
+
139
+ return preparedLng
28
140
  }
29
141
 
142
+ /**
143
+ * The language the app starts in: the one persisted under `owlmeans-lng` when it is still in
144
+ * `supportedLngs` (a person's explicit choice); else, on a first visit, the browser's own language
145
+ * (`navigator.languages` through `preferredLanguageOf` — a German browser opens in German); else
146
+ * `fallbackLng ?? defaultLng ?? DEFAULT_LNG`. The instance is initialized with this explicit `lng`,
147
+ * so a detector plugin installed afterwards (`instance.use(detector)`) is never consulted.
148
+ */
149
+ export const resolveInitialLanguage = (config: ClientConfig): string => {
150
+ const supportedLngs = config.i18n?.supportedLngs ?? [...SUPPORTED_LNGS]
151
+ const persistedLng = getPersistedLanguage()
152
+
153
+ return persistedLng != null && supportedLngs.includes(persistedLng)
154
+ ? persistedLng
155
+ : preferredLanguageOf(supportedLngs, browserLanguages()) ?? resolveFallbackLanguage(config)
156
+ }
157
+
158
+ const resolveFallbackLanguage = (config: ClientConfig): string =>
159
+ config.i18n?.fallbackLng ?? config.i18n?.defaultLng ?? DEFAULT_LNG
160
+
30
161
  const getPersistedLanguage = (): string | null => {
162
+ if (!persistenceAllowed()) {
163
+ return null
164
+ }
31
165
  try {
32
166
  return localStorage.getItem(LNG_STORAGE_KEY)
33
167
  } catch (_) {
@@ -35,17 +169,48 @@ const getPersistedLanguage = (): string | null => {
35
169
  }
36
170
  }
37
171
 
172
+ /**
173
+ * The first of the browser's preferred languages the application supports — exact (`pt-BR`) or by
174
+ * its base (`de-DE` → `de`) — or `null` when none is. Pure, so a host (or a test) can pass the list.
175
+ */
176
+ export const preferredLanguageOf = (
177
+ supportedLngs: readonly string[], preferred: readonly (string | null | undefined)[],
178
+ ): string | null => {
179
+ const supported = new Map(supportedLngs.map(lng => [lng.toLowerCase(), lng]))
180
+ for (const candidate of preferred) {
181
+ const tag = candidate?.trim().toLowerCase() ?? ''
182
+ if (tag === '') continue
183
+ const match = supported.get(tag) ?? supported.get(tag.split(/[-_]/)[0])
184
+ if (match != null) return match
185
+ }
186
+
187
+ return null
188
+ }
189
+
190
+ /** The browser's preferred languages, in order; `[]` outside a browser. */
191
+ const browserLanguages = (): readonly string[] => {
192
+ try {
193
+ if (typeof navigator === 'undefined') return []
194
+ return navigator.languages != null && navigator.languages.length > 0
195
+ ? navigator.languages
196
+ : navigator.language != null ? [navigator.language] : []
197
+ } catch (_) {
198
+ return []
199
+ }
200
+ }
201
+
38
202
  const createI18nInstance = (config: ClientConfig): i18n => {
39
203
  if (i18nInstance != null) {
40
204
  return i18nInstance
41
205
  }
42
206
 
43
- const fallbackLng = config.i18n?.fallbackLng ?? config.i18n?.defaultLng ?? DEFAULT_LNG
207
+ const fallbackLng = resolveFallbackLanguage(config)
44
208
  const supportedLngs = config.i18n?.supportedLngs ?? [...SUPPORTED_LNGS]
45
- const persistedLng = getPersistedLanguage()
46
- const lng = persistedLng != null && supportedLngs.includes(persistedLng)
47
- ? persistedLng
48
- : fallbackLng
209
+ const wantedLng = preparedLng ?? resolveInitialLanguage(config)
210
+ // Safety net for a host that did not await `prepareI18n`: start in the fallback and switch once
211
+ // the wanted language's loaders finish — a brief fallback flash instead of a half-loaded language.
212
+ const loaded = isI18nLanguageLoaded(wantedLng)
213
+ const lng = loaded ? wantedLng : fallbackLng
49
214
 
50
215
  const instance = createInstance({
51
216
  // No `compatibilityJSON` — i18next >= 26 accepts only the v4 JSON format
@@ -61,5 +226,14 @@ const createI18nInstance = (config: ClientConfig): i18n => {
61
226
 
62
227
  instance.use(initReactI18next).init()
63
228
 
64
- return i18nInstance = instance
229
+ i18nInstance = instance
230
+
231
+ if (!loaded) {
232
+ // Not persisted: `wantedLng` may be a browser-detected language nobody chose.
233
+ switchLanguage(wantedLng, false).catch((error: unknown) => {
234
+ console.error(`[i18n] failed to load pack for "${wantedLng}"`, error)
235
+ })
236
+ }
237
+
238
+ return instance
65
239
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=instance.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instance.spec.d.ts","sourceRoot":"","sources":["instance.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { preferredLanguageOf } from '../src/utils/instance.js';
3
+ const SUPPORTED = ['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr'];
4
+ describe('preferredLanguageOf — the first-visit language', () => {
5
+ test('a regional tag resolves to its supported base language', () => {
6
+ expect(preferredLanguageOf(SUPPORTED, ['de-DE'])).toBe('de');
7
+ expect(preferredLanguageOf(SUPPORTED, ['fr-FR', 'en'])).toBe('fr');
8
+ expect(preferredLanguageOf(SUPPORTED, ['pl_PL'])).toBe('pl');
9
+ });
10
+ test('the browser order decides, and an unsupported language is skipped', () => {
11
+ expect(preferredLanguageOf(SUPPORTED, ['it-IT', 'de-CH', 'en-US'])).toBe('de');
12
+ expect(preferredLanguageOf(SUPPORTED, ['EN-us'])).toBe('en');
13
+ });
14
+ test('an exact supported tag wins over its base', () => {
15
+ expect(preferredLanguageOf(['pt', 'pt-BR'], ['pt-BR'])).toBe('pt-BR');
16
+ });
17
+ test('nothing supported, or nothing known — null (the caller falls back)', () => {
18
+ expect(preferredLanguageOf(SUPPORTED, ['it-IT', 'ja'])).toBeNull();
19
+ expect(preferredLanguageOf(SUPPORTED, [])).toBeNull();
20
+ expect(preferredLanguageOf(SUPPORTED, [null, undefined, ''])).toBeNull();
21
+ });
22
+ });
23
+ //# sourceMappingURL=instance.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"instance.spec.js","sourceRoot":"","sources":["instance.spec.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,UAAU,CAAA;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAE9D,MAAM,SAAS,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA;AAElE,QAAQ,CAAC,gDAAgD,EAAE,GAAG,EAAE;IAC9D,IAAI,CAAC,wDAAwD,EAAE,GAAG,EAAE;QAClE,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC5D,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAClE,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,mEAAmE,EAAE,GAAG,EAAE;QAC7E,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC9E,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC9D,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACrD,MAAM,CAAC,mBAAmB,CAAC,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IACvE,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,oEAAoE,EAAE,GAAG,EAAE;QAC9E,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;QAClE,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;QACrD,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IAC1E,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { afterEach, beforeEach } from 'bun:test'
3
+ import {
4
+ persistLanguage, preferredLanguageOf, resolveInitialLanguage, setLanguage, setLanguagePersistence,
5
+ } from '../src/utils/instance.js'
6
+
7
+ const SUPPORTED = ['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr']
8
+
9
+ describe('preferredLanguageOf — the first-visit language', () => {
10
+ test('a regional tag resolves to its supported base language', () => {
11
+ expect(preferredLanguageOf(SUPPORTED, ['de-DE'])).toBe('de')
12
+ expect(preferredLanguageOf(SUPPORTED, ['fr-FR', 'en'])).toBe('fr')
13
+ expect(preferredLanguageOf(SUPPORTED, ['pl_PL'])).toBe('pl')
14
+ })
15
+
16
+ test('the browser order decides, and an unsupported language is skipped', () => {
17
+ expect(preferredLanguageOf(SUPPORTED, ['it-IT', 'de-CH', 'en-US'])).toBe('de')
18
+ expect(preferredLanguageOf(SUPPORTED, ['EN-us'])).toBe('en')
19
+ })
20
+
21
+ test('an exact supported tag wins over its base', () => {
22
+ expect(preferredLanguageOf(['pt', 'pt-BR'], ['pt-BR'])).toBe('pt-BR')
23
+ })
24
+
25
+ test('nothing supported, or nothing known — null (the caller falls back)', () => {
26
+ expect(preferredLanguageOf(SUPPORTED, ['it-IT', 'ja'])).toBeNull()
27
+ expect(preferredLanguageOf(SUPPORTED, [])).toBeNull()
28
+ expect(preferredLanguageOf(SUPPORTED, [null, undefined, ''])).toBeNull()
29
+ })
30
+ })
31
+
32
+ describe('language persistence guard — a choice is remembered only while storage is allowed', () => {
33
+ const store = new Map<string, string>()
34
+ const config = { i18n: { supportedLngs: SUPPORTED, fallbackLng: 'en' } } as never
35
+ let allowed = false
36
+
37
+ beforeEach(() => {
38
+ store.clear()
39
+ allowed = false
40
+ ;(globalThis as any).localStorage = {
41
+ getItem: (k: string) => store.get(k) ?? null,
42
+ setItem: (k: string, v: string) => { store.set(k, v) },
43
+ removeItem: (k: string) => { store.delete(k) },
44
+ }
45
+ setLanguagePersistence(() => allowed)
46
+ })
47
+ afterEach(() => { setLanguagePersistence(null) })
48
+
49
+ test('with the guard saying no, an explicit choice is not written', async () => {
50
+ await setLanguage('pl')
51
+
52
+ expect(store.has('owlmeans-lng')).toBe(false)
53
+ })
54
+
55
+ test('the refused choice is written the moment storage is allowed', async () => {
56
+ await setLanguage('pl')
57
+ expect(persistLanguage()).toBe(false)
58
+
59
+ allowed = true
60
+
61
+ expect(persistLanguage()).toBe(true)
62
+ expect(store.get('owlmeans-lng')).toBe('pl')
63
+ expect(persistLanguage()).toBe(false)
64
+ })
65
+
66
+ test('the latest refused choice is the one that is written', async () => {
67
+ await setLanguage('pl')
68
+ await setLanguage('de')
69
+ allowed = true
70
+ persistLanguage()
71
+
72
+ expect(store.get('owlmeans-lng')).toBe('de')
73
+ })
74
+
75
+ test('with the guard saying yes, a choice is written at once, as before', async () => {
76
+ allowed = true
77
+ await setLanguage('fr')
78
+
79
+ expect(store.get('owlmeans-lng')).toBe('fr')
80
+ })
81
+
82
+ test('a stored language counts as absent while the guard says no, and is used once it says yes', () => {
83
+ store.set('owlmeans-lng', 'de')
84
+
85
+ expect(resolveInitialLanguage(config)).not.toBe('de')
86
+
87
+ allowed = true
88
+ expect(resolveInitialLanguage(config)).toBe('de')
89
+ })
90
+
91
+ test('with no guard installed nothing changes: choices are written and read', async () => {
92
+ setLanguagePersistence(null)
93
+ await setLanguage('es')
94
+
95
+ expect(store.get('owlmeans-lng')).toBe('es')
96
+ expect(resolveInitialLanguage(config)).toBe('es')
97
+ })
98
+ })
package/tsconfig.json CHANGED
@@ -7,5 +7,5 @@
7
7
  "rootDir": "./src/",
8
8
  "outDir": "./build/"
9
9
  },
10
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
11
  }