@xndrjs/i18n-react 0.8.2 → 0.8.3
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 +125 -0
- package/dist/cli/codegen.js +305 -0
- package/dist/cli/codegen.js.map +1 -0
- package/package.json +6 -9
- package/bin/codegen.mjs +0 -17
- package/src/codegen/emit/react-bindings-file.test.ts +0 -44
- package/src/codegen/emit/react-bindings-file.ts +0 -223
- package/src/codegen/generate-react-bindings.ts +0 -86
- package/src/codegen/react-codegen-config.test.ts +0 -39
- package/src/codegen/react-codegen-config.ts +0 -70
- package/src/codegen/write-file-if-changed.ts +0 -16
- package/src/create-load-coordinator.test.ts +0 -421
- package/src/create-load-coordinator.ts +0 -340
- package/src/create-scoped-t.ts +0 -50
- package/src/index.ts +0 -18
- package/src/namespace-load-gate.test.tsx +0 -640
- package/src/namespace-load-gate.tsx +0 -285
- package/src/root-context.tsx +0 -21
- package/src/root-provider.tsx +0 -65
- package/src/runtime.test.tsx +0 -119
- package/src/types.ts +0 -138
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Load deduplication for lazy i18n namespace gates / HOCs.
|
|
3
|
-
*
|
|
4
|
-
* Supports multiple concurrent keys so parallel per-namespace gates can share
|
|
5
|
-
* one coordinator. Each `(engine, partition, namespaces)` entry keeps its own
|
|
6
|
-
* promise and status. Display state (keep-previous / pendingLocale / stable `t`)
|
|
7
|
-
* lives here so gate components stay pure observers of `useSyncExternalStore`.
|
|
8
|
-
*/
|
|
9
|
-
import { createScopedT } from "./create-scoped-t.js";
|
|
10
|
-
import type {
|
|
11
|
-
GetDisplayEntryOptions,
|
|
12
|
-
LoadCoordinator,
|
|
13
|
-
LoadCoordinatorEntry,
|
|
14
|
-
LoadCoordinatorKey,
|
|
15
|
-
LoadCoordinatorRequest,
|
|
16
|
-
LoadDisplayEntry,
|
|
17
|
-
ScopedScopeLike,
|
|
18
|
-
ScopedTranslateFn,
|
|
19
|
-
} from "./types.js";
|
|
20
|
-
|
|
21
|
-
const engineIds = new WeakMap<object, number>();
|
|
22
|
-
let nextEngineId = 1;
|
|
23
|
-
|
|
24
|
-
function engineId(ref: unknown): number {
|
|
25
|
-
if (ref === null || (typeof ref !== "object" && typeof ref !== "function")) {
|
|
26
|
-
return 0;
|
|
27
|
-
}
|
|
28
|
-
const obj = ref as object;
|
|
29
|
-
let id = engineIds.get(obj);
|
|
30
|
-
if (id === undefined) {
|
|
31
|
-
id = nextEngineId++;
|
|
32
|
-
engineIds.set(obj, id);
|
|
33
|
-
}
|
|
34
|
-
return id;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function cacheKey(
|
|
38
|
-
engineRef: unknown,
|
|
39
|
-
partition: string | undefined,
|
|
40
|
-
namespaces: readonly string[]
|
|
41
|
-
): string {
|
|
42
|
-
return `${engineId(engineRef)}\0${partition ?? ""}\0${namespaces.join("\0")}`;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Gate identity for keep-previous (namespaces only — partition/locale can change). */
|
|
46
|
-
function gateId(engineRef: unknown, namespaces: readonly string[]): string {
|
|
47
|
-
return `${engineId(engineRef)}\0${namespaces.join("\0")}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
interface CacheEntry<Scope> {
|
|
51
|
-
status: "pending" | "resolved" | "error";
|
|
52
|
-
promise: Promise<Scope>;
|
|
53
|
-
resolvedScope: Scope | null;
|
|
54
|
-
error: unknown;
|
|
55
|
-
requestId: number;
|
|
56
|
-
/** Stable LoadCoordinatorEntry snapshot for getEntry(). */
|
|
57
|
-
entrySnapshot: LoadCoordinatorEntry<Scope>;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface DisplayCache {
|
|
61
|
-
lastReady: { scope: ScopedScopeLike; locale: string } | null;
|
|
62
|
-
/** Stable display entry object until inputs change. */
|
|
63
|
-
displaySnapshot: LoadDisplayEntry<ScopedScopeLike> | null;
|
|
64
|
-
/** Identity key for the last displaySnapshot. */
|
|
65
|
-
displayKey: string | null;
|
|
66
|
-
boundT: ScopedTranslateFn | null;
|
|
67
|
-
boundTKey: string | null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Dedupes in-flight loads by `(engineRef, partition, namespaces)` and exposes
|
|
72
|
-
* display snapshots + retry for manual pending/resolved/error rendering.
|
|
73
|
-
*/
|
|
74
|
-
export function createLoadCoordinator<Scope = ScopedScopeLike>(): LoadCoordinator<Scope> {
|
|
75
|
-
let revision = 0;
|
|
76
|
-
let nextRequestId = 0;
|
|
77
|
-
const entries = new Map<string, CacheEntry<Scope>>();
|
|
78
|
-
const displayByGate = new Map<string, DisplayCache>();
|
|
79
|
-
const listeners = new Set<() => void>();
|
|
80
|
-
let lastKey: string | null = null;
|
|
81
|
-
|
|
82
|
-
function notify(): void {
|
|
83
|
-
for (const listener of listeners) {
|
|
84
|
-
listener();
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function bumpAndNotify(): void {
|
|
89
|
-
revision += 1;
|
|
90
|
-
// Invalidate display snapshots that depend on entry status.
|
|
91
|
-
for (const display of displayByGate.values()) {
|
|
92
|
-
display.displaySnapshot = null;
|
|
93
|
-
display.displayKey = null;
|
|
94
|
-
}
|
|
95
|
-
notify();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function markResolvedSync(key: string, syncScope: Scope): void {
|
|
99
|
-
const entrySnapshot: LoadCoordinatorEntry<Scope> = {
|
|
100
|
-
status: "resolved",
|
|
101
|
-
scope: syncScope,
|
|
102
|
-
};
|
|
103
|
-
entries.set(key, {
|
|
104
|
-
status: "resolved",
|
|
105
|
-
promise: Promise.resolve(syncScope),
|
|
106
|
-
resolvedScope: syncScope,
|
|
107
|
-
error: null,
|
|
108
|
-
requestId: ++nextRequestId,
|
|
109
|
-
entrySnapshot,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* SSR / getServerSnapshot: hydrate from peek/state only — never kick `load()`.
|
|
115
|
-
*/
|
|
116
|
-
function ensureSync(input: LoadCoordinatorRequest<Scope>): void {
|
|
117
|
-
const key = cacheKey(input.engineRef, input.partition, input.namespaces);
|
|
118
|
-
lastKey = key;
|
|
119
|
-
|
|
120
|
-
if (entries.has(key)) {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (input.tryResolveSync === undefined) {
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const syncScope = input.tryResolveSync();
|
|
129
|
-
if (syncScope !== null) {
|
|
130
|
-
markResolvedSync(key, syncScope);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function ensure(input: LoadCoordinatorRequest<Scope>): void {
|
|
135
|
-
const key = cacheKey(input.engineRef, input.partition, input.namespaces);
|
|
136
|
-
lastKey = key;
|
|
137
|
-
|
|
138
|
-
const existing = entries.get(key);
|
|
139
|
-
if (existing !== undefined) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (input.tryResolveSync !== undefined) {
|
|
144
|
-
const syncScope = input.tryResolveSync();
|
|
145
|
-
if (syncScope !== null) {
|
|
146
|
-
markResolvedSync(key, syncScope);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const requestId = ++nextRequestId;
|
|
152
|
-
const promise = input.load().then(
|
|
153
|
-
(scope) => {
|
|
154
|
-
const entry = entries.get(key);
|
|
155
|
-
if (entry === undefined || entry.requestId !== requestId) {
|
|
156
|
-
return scope;
|
|
157
|
-
}
|
|
158
|
-
entry.status = "resolved";
|
|
159
|
-
entry.resolvedScope = scope;
|
|
160
|
-
entry.error = null;
|
|
161
|
-
entry.entrySnapshot = { status: "resolved", scope };
|
|
162
|
-
bumpAndNotify();
|
|
163
|
-
return scope;
|
|
164
|
-
},
|
|
165
|
-
(error: unknown) => {
|
|
166
|
-
const entry = entries.get(key);
|
|
167
|
-
if (entry !== undefined && entry.requestId === requestId) {
|
|
168
|
-
entry.status = "error";
|
|
169
|
-
entry.error = error;
|
|
170
|
-
entry.resolvedScope = null;
|
|
171
|
-
entry.entrySnapshot = { status: "error", error };
|
|
172
|
-
bumpAndNotify();
|
|
173
|
-
}
|
|
174
|
-
throw error;
|
|
175
|
-
}
|
|
176
|
-
);
|
|
177
|
-
// Avoid unhandled rejection when only gate subscribers attach (no getPromise).
|
|
178
|
-
void promise.catch((error: unknown) => {
|
|
179
|
-
console.error("[i18n-react] namespace load failed:", error);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
entries.set(key, {
|
|
183
|
-
status: "pending",
|
|
184
|
-
promise,
|
|
185
|
-
resolvedScope: null,
|
|
186
|
-
error: null,
|
|
187
|
-
requestId,
|
|
188
|
-
entrySnapshot: { status: "pending" },
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function getEntry(keyInput: LoadCoordinatorKey): LoadCoordinatorEntry<Scope> {
|
|
193
|
-
const key = cacheKey(keyInput.engineRef, keyInput.partition, keyInput.namespaces);
|
|
194
|
-
const entry = entries.get(key);
|
|
195
|
-
if (entry === undefined) {
|
|
196
|
-
return { status: "pending" };
|
|
197
|
-
}
|
|
198
|
-
return entry.entrySnapshot;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function getOrCreateDisplay(engineRef: unknown, namespaces: readonly string[]): DisplayCache {
|
|
202
|
-
const id = gateId(engineRef, namespaces);
|
|
203
|
-
let display = displayByGate.get(id);
|
|
204
|
-
if (display === undefined) {
|
|
205
|
-
display = {
|
|
206
|
-
lastReady: null,
|
|
207
|
-
displaySnapshot: null,
|
|
208
|
-
displayKey: null,
|
|
209
|
-
boundT: null,
|
|
210
|
-
boundTKey: null,
|
|
211
|
-
};
|
|
212
|
-
displayByGate.set(id, display);
|
|
213
|
-
}
|
|
214
|
-
return display;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function bindT(
|
|
218
|
-
display: DisplayCache,
|
|
219
|
-
scope: ScopedScopeLike,
|
|
220
|
-
locale: string,
|
|
221
|
-
namespaces: readonly string[],
|
|
222
|
-
scopeToken: string
|
|
223
|
-
): ScopedTranslateFn {
|
|
224
|
-
const tKey = `${scopeToken}\0${locale}\0${namespaces.join("\0")}`;
|
|
225
|
-
if (display.boundT !== null && display.boundTKey === tKey) {
|
|
226
|
-
return display.boundT;
|
|
227
|
-
}
|
|
228
|
-
const t = createScopedT(scope, { namespaces, locale });
|
|
229
|
-
display.boundT = t;
|
|
230
|
-
display.boundTKey = tKey;
|
|
231
|
-
return t;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function getDisplayEntry(
|
|
235
|
-
keyInput: LoadCoordinatorKey,
|
|
236
|
-
options: GetDisplayEntryOptions
|
|
237
|
-
): LoadDisplayEntry<Scope> {
|
|
238
|
-
const key = cacheKey(keyInput.engineRef, keyInput.partition, keyInput.namespaces);
|
|
239
|
-
const entry = entries.get(key);
|
|
240
|
-
const display = getOrCreateDisplay(keyInput.engineRef, options.namespaces);
|
|
241
|
-
const keepPrevious = options.keepPrevious !== false;
|
|
242
|
-
|
|
243
|
-
const retry = () => {
|
|
244
|
-
retryKey(keyInput);
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
let result: LoadDisplayEntry<Scope>;
|
|
248
|
-
|
|
249
|
-
if (entry === undefined || entry.status === "pending") {
|
|
250
|
-
const kept = keepPrevious ? display.lastReady : null;
|
|
251
|
-
result = {
|
|
252
|
-
status: "pending",
|
|
253
|
-
display: kept as { scope: Scope & ScopedScopeLike; locale: string } | null,
|
|
254
|
-
pendingLocale: kept ? options.locale : undefined,
|
|
255
|
-
t: kept
|
|
256
|
-
? bindT(display, kept.scope, kept.locale, options.namespaces, `kept:${kept.locale}`)
|
|
257
|
-
: null,
|
|
258
|
-
} as LoadDisplayEntry<Scope>;
|
|
259
|
-
} else if (entry.status === "resolved") {
|
|
260
|
-
const scope = entry.resolvedScope as Scope & ScopedScopeLike;
|
|
261
|
-
display.lastReady = { scope, locale: options.locale };
|
|
262
|
-
result = {
|
|
263
|
-
status: "ready",
|
|
264
|
-
scope,
|
|
265
|
-
locale: options.locale,
|
|
266
|
-
t: bindT(display, scope, options.locale, options.namespaces, `ready:${key}`),
|
|
267
|
-
} as LoadDisplayEntry<Scope>;
|
|
268
|
-
} else {
|
|
269
|
-
const kept = keepPrevious ? display.lastReady : null;
|
|
270
|
-
result = {
|
|
271
|
-
status: "error",
|
|
272
|
-
error: entry.error,
|
|
273
|
-
display: kept as { scope: Scope & ScopedScopeLike; locale: string } | null,
|
|
274
|
-
pendingLocale: kept ? options.locale : undefined,
|
|
275
|
-
t: kept
|
|
276
|
-
? bindT(display, kept.scope, kept.locale, options.namespaces, `kept:${kept.locale}`)
|
|
277
|
-
: null,
|
|
278
|
-
retry,
|
|
279
|
-
} as LoadDisplayEntry<Scope>;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const displayKey = `${key}\0${result.status}\0${options.locale}\0${String(!!result.t)}`;
|
|
283
|
-
if (display.displaySnapshot !== null && display.displayKey === displayKey) {
|
|
284
|
-
return display.displaySnapshot as LoadDisplayEntry<Scope>;
|
|
285
|
-
}
|
|
286
|
-
display.displaySnapshot = result as LoadDisplayEntry<ScopedScopeLike>;
|
|
287
|
-
display.displayKey = displayKey;
|
|
288
|
-
return result;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function retryKey(keyInput: LoadCoordinatorKey): void {
|
|
292
|
-
const key = cacheKey(keyInput.engineRef, keyInput.partition, keyInput.namespaces);
|
|
293
|
-
if (!entries.has(key)) {
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
entries.delete(key);
|
|
297
|
-
bumpAndNotify();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function subscribe(listener: () => void): () => void {
|
|
301
|
-
listeners.add(listener);
|
|
302
|
-
return () => {
|
|
303
|
-
listeners.delete(listener);
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function getPromise(): Promise<Scope> {
|
|
308
|
-
if (lastKey === null) {
|
|
309
|
-
throw new Error("createLoadCoordinator: call ensure()/request() before getPromise()");
|
|
310
|
-
}
|
|
311
|
-
const entry = entries.get(lastKey);
|
|
312
|
-
if (entry === undefined) {
|
|
313
|
-
throw new Error("createLoadCoordinator: call ensure()/request() before getPromise()");
|
|
314
|
-
}
|
|
315
|
-
return entry.promise;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function getResolvedScope(): Scope | null {
|
|
319
|
-
if (lastKey === null) {
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
const entry = entries.get(lastKey);
|
|
323
|
-
return entry?.status === "resolved" ? entry.resolvedScope : null;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return {
|
|
327
|
-
get revision() {
|
|
328
|
-
return revision;
|
|
329
|
-
},
|
|
330
|
-
request: ensure,
|
|
331
|
-
ensure,
|
|
332
|
-
ensureSync,
|
|
333
|
-
getEntry,
|
|
334
|
-
getDisplayEntry,
|
|
335
|
-
retry: retryKey,
|
|
336
|
-
subscribe,
|
|
337
|
-
getPromise,
|
|
338
|
-
getResolvedScope,
|
|
339
|
-
};
|
|
340
|
-
}
|
package/src/create-scoped-t.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Builds the scoped `t` function used by providers and load gates.
|
|
3
|
-
*
|
|
4
|
-
* Takes a loaded `@xndrjs/i18n` scope and returns a capability-safe translator:
|
|
5
|
-
* namespaces outside the provider list throw at runtime. Locale is always bound.
|
|
6
|
-
*/
|
|
7
|
-
import type { CreateScopedTOptions, ScopedScopeLike, ScopedTranslateFn } from "./types.js";
|
|
8
|
-
|
|
9
|
-
function assertNamespace(namespace: string, namespaces: readonly string[]): void {
|
|
10
|
-
if (!namespaces.includes(namespace)) {
|
|
11
|
-
throw new Error(
|
|
12
|
-
`Namespace "${namespace}" is not loaded by this provider. Allowed: [${namespaces.join(", ")}]`
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function resolveActiveScope(scope: ScopedScopeLike, locale: string): ScopedScopeLike {
|
|
18
|
-
if (typeof scope.forLocale === "function" && scope.locale !== locale) {
|
|
19
|
-
return scope.forLocale(locale);
|
|
20
|
-
}
|
|
21
|
-
return scope;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Wraps a loaded scope `t` with namespace guards and locale binding.
|
|
26
|
-
*/
|
|
27
|
-
export function createScopedT(
|
|
28
|
-
scope: ScopedScopeLike,
|
|
29
|
-
options: CreateScopedTOptions
|
|
30
|
-
): ScopedTranslateFn {
|
|
31
|
-
const activeScope = resolveActiveScope(scope, options.locale);
|
|
32
|
-
const { namespaces } = options;
|
|
33
|
-
|
|
34
|
-
return ((namespace: string, key: string, ...rest: unknown[]) => {
|
|
35
|
-
assertNamespace(namespace, namespaces);
|
|
36
|
-
return activeScope.t(namespace, key, ...rest);
|
|
37
|
-
}) as ScopedTranslateFn;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Curries a multi-namespace context `t(ns, key, …)` into a namespace-bound `t(key, …)`.
|
|
42
|
-
* Used when composing gate values without introducing a separate hooks API.
|
|
43
|
-
*/
|
|
44
|
-
export function bindNamespaceTranslate(
|
|
45
|
-
t: ScopedTranslateFn,
|
|
46
|
-
namespace: string
|
|
47
|
-
): (key: string, params?: Record<string, unknown>) => string {
|
|
48
|
-
return (key: string, params?: Record<string, unknown>) =>
|
|
49
|
-
params === undefined ? t(namespace, key) : t(namespace, key, params);
|
|
50
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public entry point for `@xndrjs/i18n-react`.
|
|
3
|
-
*
|
|
4
|
-
* Only symbols imported by `react-bindings.generated.tsx` (and tests) are exported.
|
|
5
|
-
*/
|
|
6
|
-
export { bindNamespaceTranslate, createScopedT } from "./create-scoped-t.js";
|
|
7
|
-
export { createLoadCoordinator } from "./create-load-coordinator.js";
|
|
8
|
-
export {
|
|
9
|
-
createI18nLoadGate,
|
|
10
|
-
useNamespaceLoad,
|
|
11
|
-
type WithI18nRender,
|
|
12
|
-
type WithI18nFallback,
|
|
13
|
-
type I18nLoadGateValue,
|
|
14
|
-
type I18nGateProps,
|
|
15
|
-
type I18nHocOptions,
|
|
16
|
-
} from "./namespace-load-gate.js";
|
|
17
|
-
export { useI18nRootContext } from "./root-context.js";
|
|
18
|
-
export { I18nRootProvider, type I18nRootProviderProps } from "./root-provider.js";
|