i18n-keyless-react 2.0.0 → 2.2.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/dist/I18nKeylessText.js +8 -6
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/request-scope.d.ts +36 -0
- package/dist/request-scope.js +63 -0
- package/dist/store.d.ts +16 -1
- package/dist/store.js +65 -13
- package/package.json +2 -2
package/dist/I18nKeylessText.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { useEffect, useMemo } from "react";
|
|
3
3
|
import { useI18nKeyless, getTranslation } from "./store.js";
|
|
4
4
|
import { useI18nKeylessContext } from "./I18nKeylessProvider.js";
|
|
5
|
+
import { getRequestScope } from "./request-scope.js";
|
|
5
6
|
const warnAboutWhitespace = (text) => {
|
|
6
7
|
if (process.env.NODE_ENV === "development" && text !== text.trim()) {
|
|
7
8
|
console.warn(`I18nKeylessText received text with leading/trailing whitespace: "${text}". ` +
|
|
@@ -12,12 +13,13 @@ export const I18nKeylessText = ({ children, replace, context, debug = false, for
|
|
|
12
13
|
const storeTranslations = useI18nKeyless((store) => store.translations);
|
|
13
14
|
const storeCurrentLanguage = useI18nKeyless((store) => store.currentLanguage);
|
|
14
15
|
const config = useI18nKeyless((store) => store.config);
|
|
15
|
-
// In SSR
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
const
|
|
16
|
+
// In SSR mode, language and translations come from the per-request scope so concurrent
|
|
17
|
+
// requests don't share state: the React-context provider first, then the
|
|
18
|
+
// AsyncLocalStorage request scope (set by runWithI18nKeyless). Otherwise (SPA mode)
|
|
19
|
+
// both are absent and we use the global store. See docs/SSR.md.
|
|
20
|
+
const requestScope = useI18nKeylessContext() ?? getRequestScope();
|
|
21
|
+
const translations = requestScope?.translations ?? storeTranslations;
|
|
22
|
+
const currentLanguage = requestScope?.lang ?? storeCurrentLanguage;
|
|
21
23
|
// Trim the source text immediately
|
|
22
24
|
const rawText = Array.isArray(children) ? children.join("") : String(children ?? "");
|
|
23
25
|
const sourceText = rawText.trim();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { I18nKeylessText, I18nKeylessText as T } from "./I18nKeylessText.tsx";
|
|
2
|
-
export { init, setCurrentLanguage, useCurrentLanguage, getTranslation, getSupportedLanguages, useI18nKeyless } from "./store.ts";
|
|
2
|
+
export { init, hydrateFromServer, setCurrentLanguage, useCurrentLanguage, getTranslation, getSupportedLanguages, useI18nKeyless } from "./store.ts";
|
|
3
3
|
export { clearI18nKeylessStorage, validateLanguage, createMemoryStorage } from "./utils.ts";
|
|
4
4
|
export { I18nKeylessProvider, useI18nKeylessContext } from "./I18nKeylessProvider.tsx";
|
|
5
5
|
export type { I18nKeylessProviderProps } from "./I18nKeylessProvider.tsx";
|
|
6
6
|
export { getServerTranslations, clearServerTranslationsCache } from "./server.ts";
|
|
7
|
+
export { runWithI18nKeyless, getRequestScope } from "./request-scope.ts";
|
|
8
|
+
export type { I18nRequestScope } from "./request-scope.ts";
|
|
7
9
|
export { clearI18nKeylessStorageAndStore } from "./store.ts";
|
|
8
10
|
export type { I18nKeylessTextProps } from "./I18nKeylessText.tsx";
|
|
9
11
|
export { type I18nConfig, type TranslationStoreState, type TranslationOptions, type TranslationStore } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { I18nKeylessText, I18nKeylessText as T } from "./I18nKeylessText.js";
|
|
2
|
-
export { init, setCurrentLanguage, useCurrentLanguage, getTranslation, getSupportedLanguages, useI18nKeyless } from "./store.js";
|
|
2
|
+
export { init, hydrateFromServer, setCurrentLanguage, useCurrentLanguage, getTranslation, getSupportedLanguages, useI18nKeyless } from "./store.js";
|
|
3
3
|
export { clearI18nKeylessStorage, validateLanguage, createMemoryStorage } from "./utils.js";
|
|
4
4
|
export { I18nKeylessProvider, useI18nKeylessContext } from "./I18nKeylessProvider.js";
|
|
5
5
|
export { getServerTranslations, clearServerTranslationsCache } from "./server.js";
|
|
6
|
+
export { runWithI18nKeyless, getRequestScope } from "./request-scope.js";
|
|
6
7
|
export { clearI18nKeylessStorageAndStore } from "./store.js";
|
|
7
8
|
export { AVAILABLE_LANGS, getAllTranslationsFromLanguage, queue } from "i18n-keyless-core";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Lang, Translations } from "i18n-keyless-core";
|
|
2
|
+
/**
|
|
3
|
+
* Per-request translation scope for SSR.
|
|
4
|
+
*
|
|
5
|
+
* Set by `runWithI18nKeyless` and read by both `getTranslation(...)` and
|
|
6
|
+
* `<I18nKeylessText>` so a single server render resolves in `lang` using `translations`,
|
|
7
|
+
* without touching the process-global store and without leaking across concurrent
|
|
8
|
+
* requests. See docs/SSR.md.
|
|
9
|
+
*/
|
|
10
|
+
export interface I18nRequestScope {
|
|
11
|
+
lang: Lang;
|
|
12
|
+
translations: Translations;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Runs `fn` with a per-request translation scope active. Every `getTranslation(...)`
|
|
16
|
+
* call and every `<I18nKeylessText>` rendered within `fn` — synchronously OR across
|
|
17
|
+
* `await`s/streaming — resolves in `scope.lang` using `scope.translations`, with no
|
|
18
|
+
* change to the global store and full isolation between concurrent requests.
|
|
19
|
+
*
|
|
20
|
+
* Wrap your server render in it and await the result:
|
|
21
|
+
*
|
|
22
|
+
* const html = await runWithI18nKeyless(
|
|
23
|
+
* { lang, translations },
|
|
24
|
+
* () => renderToString(<App />)
|
|
25
|
+
* );
|
|
26
|
+
*
|
|
27
|
+
* Server-only. In the browser (or where AsyncLocalStorage is unavailable) it simply
|
|
28
|
+
* calls `fn` with no scoping. See docs/SSR.md.
|
|
29
|
+
*/
|
|
30
|
+
export declare function runWithI18nKeyless<R>(scope: I18nRequestScope, fn: () => R): Promise<R>;
|
|
31
|
+
/**
|
|
32
|
+
* Returns the active per-request scope, or `undefined` when none is set (browser, or
|
|
33
|
+
* outside `runWithI18nKeyless`). Read internally by `getTranslation` and
|
|
34
|
+
* `<I18nKeylessText>`; exported for advanced/server use.
|
|
35
|
+
*/
|
|
36
|
+
export declare function getRequestScope(): I18nRequestScope | undefined;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// A single AsyncLocalStorage instance, created lazily on the server only. Each
|
|
10
|
+
// `run()` creates an isolated store, so the instance is correctly a module singleton.
|
|
11
|
+
let als;
|
|
12
|
+
let alsInit;
|
|
13
|
+
async function ensureALS() {
|
|
14
|
+
// Browser: no request scoping (single user → the store is correct). Also avoids
|
|
15
|
+
// pulling a Node builtin into client bundles.
|
|
16
|
+
if (als || typeof window !== "undefined") {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!alsInit) {
|
|
20
|
+
alsInit = (async () => {
|
|
21
|
+
try {
|
|
22
|
+
// Variable specifier + ignore hints keep bundlers from trying to resolve a Node
|
|
23
|
+
// builtin into the browser graph. tsc keeps these comments (removeComments=false).
|
|
24
|
+
const specifier = "node:async_hooks";
|
|
25
|
+
const mod = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ /* webpackIgnore: true */ specifier)));
|
|
26
|
+
als = new mod.AsyncLocalStorage();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// AsyncLocalStorage unavailable (e.g. some edge runtimes). Scoping degrades to a
|
|
30
|
+
// no-op and getTranslation/<T> fall back to the global store. See docs/SSR.md.
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
}
|
|
34
|
+
await alsInit;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Runs `fn` with a per-request translation scope active. Every `getTranslation(...)`
|
|
38
|
+
* call and every `<I18nKeylessText>` rendered within `fn` — synchronously OR across
|
|
39
|
+
* `await`s/streaming — resolves in `scope.lang` using `scope.translations`, with no
|
|
40
|
+
* change to the global store and full isolation between concurrent requests.
|
|
41
|
+
*
|
|
42
|
+
* Wrap your server render in it and await the result:
|
|
43
|
+
*
|
|
44
|
+
* const html = await runWithI18nKeyless(
|
|
45
|
+
* { lang, translations },
|
|
46
|
+
* () => renderToString(<App />)
|
|
47
|
+
* );
|
|
48
|
+
*
|
|
49
|
+
* Server-only. In the browser (or where AsyncLocalStorage is unavailable) it simply
|
|
50
|
+
* calls `fn` with no scoping. See docs/SSR.md.
|
|
51
|
+
*/
|
|
52
|
+
export async function runWithI18nKeyless(scope, fn) {
|
|
53
|
+
await ensureALS();
|
|
54
|
+
return als ? als.run(scope, fn) : fn();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Returns the active per-request scope, or `undefined` when none is set (browser, or
|
|
58
|
+
* outside `runWithI18nKeyless`). Read internally by `getTranslation` and
|
|
59
|
+
* `<I18nKeylessText>`; exported for advanced/server use.
|
|
60
|
+
*/
|
|
61
|
+
export function getRequestScope() {
|
|
62
|
+
return als?.getStore();
|
|
63
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import { type Lang, type TranslationOptions } from "i18n-keyless-core";
|
|
1
|
+
import { type Lang, type Translations, type TranslationOptions } from "i18n-keyless-core";
|
|
2
2
|
import { type I18nConfig, type TranslationStore } from "./types.ts";
|
|
3
3
|
export declare const useI18nKeyless: import("zustand").UseBoundStore<import("zustand").StoreApi<TranslationStore>>;
|
|
4
|
+
/**
|
|
5
|
+
* Synchronously seeds the store from a server snapshot, BEFORE React's first client
|
|
6
|
+
* render, so the imperative `getTranslation(key)` returns the correct language on the
|
|
7
|
+
* very first render — no hydration mismatch, no blink. Call it in your client entry,
|
|
8
|
+
* before `hydrateRoot`, with the `{ lang, translations }` the server serialized into the
|
|
9
|
+
* HTML (the server can read it from `getRequestScope()`).
|
|
10
|
+
*
|
|
11
|
+
* The component path (`<I18nKeylessText>`) is covered by `<I18nKeylessProvider>`; the
|
|
12
|
+
* function path needs this synchronous store seed because a plain function cannot read
|
|
13
|
+
* React context. Server-only async `hydrate()` will not overwrite this seed. See docs/SSR.md.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hydrateFromServer(snapshot?: {
|
|
16
|
+
lang?: Lang;
|
|
17
|
+
translations?: Translations;
|
|
18
|
+
}): void;
|
|
4
19
|
/**
|
|
5
20
|
* Initializes the i18n configuration with defaults and validation
|
|
6
21
|
* @param newConfig - The configuration object to initialize
|
package/dist/store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { queue, getAllTranslationsFromLanguage, getTranslationCore, sendTranslationsUsageToI18nKeyless, } from "i18n-keyless-core";
|
|
2
2
|
import { create } from "zustand";
|
|
3
3
|
import { storeKeys, setItem, getItem, clearI18nKeylessStorage, validateLanguage, createMemoryStorage } from "./utils.js";
|
|
4
|
+
import { getRequestScope } from "./request-scope.js";
|
|
4
5
|
/**
|
|
5
6
|
* True when running without a DOM (server-side rendering). On the server the lib is
|
|
6
7
|
* read-only: usage analytics are neither recorded nor sent. Evaluated at call time so
|
|
@@ -9,6 +10,14 @@ import { storeKeys, setItem, getItem, clearI18nKeylessStorage, validateLanguage,
|
|
|
9
10
|
function isServerEnv() {
|
|
10
11
|
return typeof window === "undefined";
|
|
11
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* True once a server snapshot has been applied synchronously on the client (via
|
|
15
|
+
* `hydrateFromServer`). When set, the async `hydrate()` treats the snapshot as
|
|
16
|
+
* authoritative for the current request and does not overwrite the seeded language /
|
|
17
|
+
* translations from storage (which may hold a different, stale language). Never set in
|
|
18
|
+
* SPA mode, so SPA hydration is unchanged. See docs/SSR.md.
|
|
19
|
+
*/
|
|
20
|
+
let serverSnapshotApplied = false;
|
|
12
21
|
queue.on("empty", () => {
|
|
13
22
|
// when each word is translated, fetch the translations for the current language
|
|
14
23
|
const store = useI18nKeyless.getState();
|
|
@@ -119,6 +128,28 @@ export const useI18nKeyless = create((set, get) => ({
|
|
|
119
128
|
}
|
|
120
129
|
},
|
|
121
130
|
}));
|
|
131
|
+
/**
|
|
132
|
+
* Synchronously seeds the store from a server snapshot, BEFORE React's first client
|
|
133
|
+
* render, so the imperative `getTranslation(key)` returns the correct language on the
|
|
134
|
+
* very first render — no hydration mismatch, no blink. Call it in your client entry,
|
|
135
|
+
* before `hydrateRoot`, with the `{ lang, translations }` the server serialized into the
|
|
136
|
+
* HTML (the server can read it from `getRequestScope()`).
|
|
137
|
+
*
|
|
138
|
+
* The component path (`<I18nKeylessText>`) is covered by `<I18nKeylessProvider>`; the
|
|
139
|
+
* function path needs this synchronous store seed because a plain function cannot read
|
|
140
|
+
* React context. Server-only async `hydrate()` will not overwrite this seed. See docs/SSR.md.
|
|
141
|
+
*/
|
|
142
|
+
export function hydrateFromServer(snapshot) {
|
|
143
|
+
if (!snapshot?.lang) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
serverSnapshotApplied = true;
|
|
147
|
+
const current = useI18nKeyless.getState();
|
|
148
|
+
useI18nKeyless.setState({
|
|
149
|
+
currentLanguage: snapshot.lang,
|
|
150
|
+
translations: { ...current.translations, ...(snapshot.translations ?? {}) },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
122
153
|
async function hydrate() {
|
|
123
154
|
const config = useI18nKeyless.getState().config;
|
|
124
155
|
if (!config.API_KEY) {
|
|
@@ -129,15 +160,21 @@ async function hydrate() {
|
|
|
129
160
|
if (!storage) {
|
|
130
161
|
throw new Error(`i18n-keyless: storage is not initialized hydrating`);
|
|
131
162
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
163
|
+
// When a server snapshot was applied, it is authoritative for the current request's
|
|
164
|
+
// language: skip loading translations / currentLanguage from storage (storage holds a
|
|
165
|
+
// single, possibly different/stale language and would clobber or mix the seed). Usage,
|
|
166
|
+
// uniqueId and lastRefresh are language-independent and still hydrate normally.
|
|
167
|
+
if (!serverSnapshotApplied) {
|
|
168
|
+
const translations = await getItem(storeKeys.translations, storage, JSON.parse);
|
|
169
|
+
if (translations) {
|
|
170
|
+
if (debug)
|
|
171
|
+
console.log("i18n-keyless: _hydrate", translations);
|
|
172
|
+
useI18nKeyless.setState({ translations: translations });
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
if (debug)
|
|
176
|
+
console.log("i18n-keyless: _hydrate: no translations");
|
|
177
|
+
}
|
|
141
178
|
}
|
|
142
179
|
const translationsUsage = await getItem(storeKeys.translationsUsage, storage, JSON.parse);
|
|
143
180
|
if (translationsUsage) {
|
|
@@ -151,7 +188,12 @@ async function hydrate() {
|
|
|
151
188
|
}
|
|
152
189
|
const currentLanguage = await getItem(storeKeys.currentLanguage, storage);
|
|
153
190
|
const skipCurrentLanguageHydration = config.languages.skipCurrentLanguageHydration;
|
|
154
|
-
if (
|
|
191
|
+
if (serverSnapshotApplied) {
|
|
192
|
+
// keep the synchronously-seeded language; do not override it from storage
|
|
193
|
+
if (debug)
|
|
194
|
+
console.log("i18n-keyless: _hydrate: keeping server-seeded language");
|
|
195
|
+
}
|
|
196
|
+
else if (skipCurrentLanguageHydration) {
|
|
155
197
|
if (debug)
|
|
156
198
|
console.log("i18n-keyless: _hydrate: skip current language hydration");
|
|
157
199
|
useI18nKeyless.setState({ currentLanguage: config?.languages.initWithDefault });
|
|
@@ -243,11 +285,21 @@ export function getSupportedLanguages() {
|
|
|
243
285
|
return useI18nKeyless.getState().config.languages.supported;
|
|
244
286
|
}
|
|
245
287
|
export function getTranslation(key, options) {
|
|
246
|
-
const
|
|
288
|
+
const base = useI18nKeyless.getState();
|
|
247
289
|
// Read-only on the server: don't record usage (a render may be a crawler hit).
|
|
248
|
-
|
|
249
|
-
|
|
290
|
+
// On the client, DEFER the usage write: getTranslation is called during component
|
|
291
|
+
// render, and setTranslationUsage writes to the store synchronously, which makes React
|
|
292
|
+
// log "Cannot update a component while rendering a different component". Usage analytics
|
|
293
|
+
// never needs to affect the current render, so flush it on a microtask (after render).
|
|
294
|
+
if (!isServerEnv() && !base.config.ssr) {
|
|
295
|
+
queueMicrotask(() => base.setTranslationUsage(key, options?.context));
|
|
250
296
|
}
|
|
297
|
+
// SSR: if a per-request scope is active (set by runWithI18nKeyless), translate against
|
|
298
|
+
// that request's language/translations instead of the process-global store — so
|
|
299
|
+
// getTranslation, like <T>, renders the right language without leaking across
|
|
300
|
+
// concurrent requests. No scope (SPA / outside a scoped render) → use the store as-is.
|
|
301
|
+
const scope = getRequestScope();
|
|
302
|
+
const store = scope ? { ...base, currentLanguage: scope.lang, translations: scope.translations } : base;
|
|
251
303
|
return getTranslationCore(key, store, options);
|
|
252
304
|
}
|
|
253
305
|
export function setCurrentLanguage(lang) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-keyless-react",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"postpublish": "rm -rf ./dist && rm *.tgz"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"i18n-keyless-core": "2.
|
|
27
|
+
"i18n-keyless-core": "2.2.0",
|
|
28
28
|
"zustand": "^5.0.3"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|