@xndrjs/i18n-react 0.8.1 → 0.8.2-alpha.1

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.
@@ -1,70 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { z } from "zod";
4
-
5
- export const DEFAULT_REACT_BINDINGS_BASENAME = "react-bindings.generated.tsx";
6
-
7
- const reactCodegenConfigSchema = z
8
- .object({
9
- output: z.string().min(1).optional(),
10
- })
11
- .strict();
12
-
13
- export type ReactCodegenConfig = z.infer<typeof reactCodegenConfigSchema>;
14
-
15
- export function defaultReactBindingsOutput(instanceOutput: string): string {
16
- return path.join(path.dirname(instanceOutput), DEFAULT_REACT_BINDINGS_BASENAME);
17
- }
18
-
19
- /** Loads optional `i18n-react.codegen.json` when present. */
20
- export function loadReactCodegenConfig(configPath: string): ReactCodegenConfig {
21
- if (!fs.existsSync(configPath)) {
22
- return {};
23
- }
24
-
25
- let raw: unknown;
26
- try {
27
- raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
28
- } catch (error) {
29
- const message = error instanceof Error ? error.message : String(error);
30
- throw new Error(
31
- `[i18n-react Codegen Error] Failed to parse react config JSON (${configPath}): ${message}`
32
- );
33
- }
34
-
35
- const result = reactCodegenConfigSchema.safeParse(raw);
36
- if (!result.success) {
37
- const issueLines = result.error.issues.map((issue) => {
38
- const issuePath = issue.path.length > 0 ? issue.path.join(".") : "(root)";
39
- return ` - ${issuePath}: ${issue.message}`;
40
- });
41
- throw new Error(
42
- ["[i18n-react Codegen Error] Invalid i18n-react.codegen.json:", ...issueLines].join("\n")
43
- );
44
- }
45
-
46
- return result.data;
47
- }
48
-
49
- export function resolveReactBindingsOutputPath(options: {
50
- instanceOutput: string;
51
- cliOut?: string;
52
- reactConfig?: ReactCodegenConfig;
53
- }): string {
54
- if (options.cliOut !== undefined) {
55
- return options.cliOut;
56
- }
57
- if (options.reactConfig?.output !== undefined) {
58
- return options.reactConfig.output;
59
- }
60
- return defaultReactBindingsOutput(options.instanceOutput);
61
- }
62
-
63
- export function defaultReactConfigPath(i18nConfigPath: string): string {
64
- const dir = path.dirname(i18nConfigPath);
65
- const base = path.basename(i18nConfigPath, path.extname(i18nConfigPath));
66
- if (base === "i18n.codegen") {
67
- return path.join(dir, "i18n-react.codegen.json");
68
- }
69
- return path.join(dir, `${base}.react.codegen.json`);
70
- }
@@ -1,16 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- /** Writes only when content changed — stable mtimes for watchers. */
5
- export function writeFileIfChanged(absolutePath: string, content: string): boolean {
6
- if (fs.existsSync(absolutePath)) {
7
- const currentContent = fs.readFileSync(absolutePath, "utf8");
8
- if (currentContent === content) {
9
- return false;
10
- }
11
- }
12
-
13
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
14
- fs.writeFileSync(absolutePath, content);
15
- return true;
16
- }
@@ -1,421 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { createI18nHandle } from "@xndrjs/i18n";
3
- import { IcuTranslationProviderMulti } from "@xndrjs/i18n";
4
- import { createLoadCoordinator } from "./create-load-coordinator.js";
5
-
6
- type MultiSchema = {
7
- default: {
8
- greeting: { en: string; it: string };
9
- };
10
- billing: {
11
- invoice: { en: string; it: string };
12
- };
13
- };
14
-
15
- type TestMultiParams = {
16
- default: {
17
- greeting: never;
18
- };
19
- billing: {
20
- invoice: never;
21
- };
22
- };
23
-
24
- const defaultEn = {
25
- greeting: { en: "Hello" },
26
- };
27
-
28
- const defaultIt = {
29
- greeting: { it: "Ciao" },
30
- };
31
-
32
- function deferred<T>() {
33
- let resolve!: (value: T) => void;
34
- let reject!: (reason?: unknown) => void;
35
- const promise = new Promise<T>((res, rej) => {
36
- resolve = res;
37
- reject = rej;
38
- });
39
- return { promise, resolve, reject };
40
- }
41
-
42
- describe("createLoadCoordinator", () => {
43
- it("dedupes in-flight loads for the same key", async () => {
44
- const coordinator = createLoadCoordinator<string>();
45
- const load = vi.fn(() => Promise.resolve("loaded"));
46
- const engine = {};
47
- const key = { engineRef: engine, partition: "en", namespaces: ["default"] as const };
48
-
49
- coordinator.request({ ...key, load });
50
- coordinator.request({ ...key, load });
51
-
52
- expect(load).toHaveBeenCalledTimes(1);
53
- expect(coordinator.getEntry(key)).toEqual({ status: "pending" });
54
- await expect(coordinator.getPromise()).resolves.toBe("loaded");
55
- expect(coordinator.revision).toBe(1);
56
- expect(coordinator.getResolvedScope()).toBe("loaded");
57
- expect(coordinator.getEntry(key)).toEqual({ status: "resolved", scope: "loaded" });
58
- });
59
-
60
- it("skips reload when the same key is already resolved", async () => {
61
- const coordinator = createLoadCoordinator<number>();
62
- const load = vi.fn(() => Promise.resolve(42));
63
- const engine = {};
64
- const key = {
65
- engineRef: engine,
66
- partition: undefined,
67
- namespaces: ["default"] as const,
68
- };
69
-
70
- coordinator.request({ ...key, load });
71
- await coordinator.getPromise();
72
-
73
- coordinator.request({ ...key, load });
74
-
75
- expect(load).toHaveBeenCalledTimes(1);
76
- expect(coordinator.revision).toBe(1);
77
- await expect(coordinator.getPromise()).resolves.toBe(42);
78
- expect(coordinator.getEntry(key)).toEqual({ status: "resolved", scope: 42 });
79
- });
80
-
81
- it("ensureSync resolves from tryResolveSync without calling load", () => {
82
- const coordinator = createLoadCoordinator<string>();
83
- const load = vi.fn(() => Promise.resolve("async"));
84
- const tryResolveSync = vi.fn(() => "sync");
85
- const engine = {};
86
- const key = { engineRef: engine, partition: "en", namespaces: ["default"] as const };
87
-
88
- coordinator.ensureSync({ ...key, load, tryResolveSync });
89
-
90
- expect(tryResolveSync).toHaveBeenCalledTimes(1);
91
- expect(load).not.toHaveBeenCalled();
92
- expect(coordinator.getEntry(key)).toEqual({ status: "resolved", scope: "sync" });
93
- });
94
-
95
- it("ensureSync does nothing when tryResolveSync misses (no async kick)", () => {
96
- const coordinator = createLoadCoordinator<string>();
97
- const load = vi.fn(() => Promise.resolve("async"));
98
- const engine = {};
99
- const key = { engineRef: engine, partition: "en", namespaces: ["default"] as const };
100
-
101
- coordinator.ensureSync({ ...key, load, tryResolveSync: () => null });
102
-
103
- expect(load).not.toHaveBeenCalled();
104
- expect(coordinator.getEntry(key)).toEqual({ status: "pending" });
105
- });
106
-
107
- it("ensureSync without tryResolveSync leaves entry absent", () => {
108
- const coordinator = createLoadCoordinator<string>();
109
- const load = vi.fn(() => Promise.resolve("async"));
110
- const engine = {};
111
- const key = { engineRef: engine, partition: "en", namespaces: ["default"] as const };
112
-
113
- coordinator.ensureSync({ ...key, load });
114
-
115
- expect(load).not.toHaveBeenCalled();
116
- expect(coordinator.getEntry(key)).toEqual({ status: "pending" });
117
- });
118
-
119
- it("resolves synchronously when tryResolveSync returns a scope", () => {
120
- const coordinator = createLoadCoordinator<string>();
121
- const load = vi.fn(() => Promise.resolve("async"));
122
- const tryResolveSync = vi.fn(() => "sync");
123
- const engine = {};
124
- const key = {
125
- engineRef: engine,
126
- partition: "en",
127
- namespaces: ["default"] as const,
128
- };
129
-
130
- coordinator.request({ ...key, load, tryResolveSync });
131
-
132
- expect(load).not.toHaveBeenCalled();
133
- expect(tryResolveSync).toHaveBeenCalledTimes(1);
134
- expect(coordinator.getEntry(key)).toEqual({ status: "resolved", scope: "sync" });
135
- expect(coordinator.getResolvedScope()).toBe("sync");
136
- });
137
-
138
- it("falls back to async load when tryResolveSync returns null", async () => {
139
- const coordinator = createLoadCoordinator<string>();
140
- const load = vi.fn(() => Promise.resolve("async"));
141
- const tryResolveSync = vi.fn(() => null);
142
- const engine = {};
143
- const key = {
144
- engineRef: engine,
145
- partition: "en",
146
- namespaces: ["default"] as const,
147
- };
148
-
149
- coordinator.request({ ...key, load, tryResolveSync });
150
-
151
- expect(coordinator.getEntry(key)).toEqual({ status: "pending" });
152
- await expect(coordinator.getPromise()).resolves.toBe("async");
153
- expect(load).toHaveBeenCalledTimes(1);
154
- expect(coordinator.getEntry(key)).toEqual({ status: "resolved", scope: "async" });
155
- });
156
-
157
- it("keeps concurrent namespace loads independent", async () => {
158
- const coordinator = createLoadCoordinator<string>();
159
- const billing = deferred<string>();
160
- const user = deferred<string>();
161
- const engine = {};
162
- const billingKey = {
163
- engineRef: engine,
164
- partition: "en",
165
- namespaces: ["billing"] as const,
166
- };
167
- const userKey = {
168
- engineRef: engine,
169
- partition: "en",
170
- namespaces: ["user"] as const,
171
- };
172
-
173
- coordinator.request({ ...billingKey, load: () => billing.promise });
174
- const billingPromise = coordinator.getPromise();
175
-
176
- coordinator.request({ ...userKey, load: () => user.promise });
177
- const userPromise = coordinator.getPromise();
178
-
179
- expect(coordinator.getEntry(billingKey)).toEqual({ status: "pending" });
180
- expect(coordinator.getEntry(userKey)).toEqual({ status: "pending" });
181
-
182
- billing.resolve("billing-scope");
183
- user.resolve("user-scope");
184
-
185
- await expect(billingPromise).resolves.toBe("billing-scope");
186
- await expect(userPromise).resolves.toBe("user-scope");
187
- expect(coordinator.revision).toBe(2);
188
- expect(coordinator.getEntry(billingKey)).toEqual({
189
- status: "resolved",
190
- scope: "billing-scope",
191
- });
192
- expect(coordinator.getEntry(userKey)).toEqual({
193
- status: "resolved",
194
- scope: "user-scope",
195
- });
196
- });
197
-
198
- it("caches distinct partitions without cancelling the earlier one", async () => {
199
- const coordinator = createLoadCoordinator<string>();
200
- const first = deferred<string>();
201
- const second = deferred<string>();
202
- const engine = {};
203
-
204
- coordinator.request({
205
- engineRef: engine,
206
- partition: "en",
207
- namespaces: ["default"],
208
- load: () => first.promise,
209
- });
210
- const enPromise = coordinator.getPromise();
211
-
212
- coordinator.request({
213
- engineRef: engine,
214
- partition: "it",
215
- namespaces: ["default"],
216
- load: () => second.promise,
217
- });
218
- const itPromise = coordinator.getPromise();
219
-
220
- first.resolve("en-scope");
221
- second.resolve("it-scope");
222
-
223
- await expect(enPromise).resolves.toBe("en-scope");
224
- await expect(itPromise).resolves.toBe("it-scope");
225
- expect(coordinator.revision).toBe(2);
226
- });
227
-
228
- it("notifies subscribers when a load resolves", async () => {
229
- const coordinator = createLoadCoordinator<string>();
230
- const pending = deferred<string>();
231
- const engine = {};
232
- const key = {
233
- engineRef: engine,
234
- partition: "en",
235
- namespaces: ["default"] as const,
236
- };
237
- const listener = vi.fn();
238
-
239
- const unsubscribe = coordinator.subscribe(listener);
240
- coordinator.request({ ...key, load: () => pending.promise });
241
-
242
- expect(listener).not.toHaveBeenCalled();
243
- expect(coordinator.getEntry(key)).toEqual({ status: "pending" });
244
-
245
- pending.resolve("ready");
246
- await expect(coordinator.getPromise()).resolves.toBe("ready");
247
-
248
- expect(listener).toHaveBeenCalledTimes(1);
249
- expect(coordinator.revision).toBe(1);
250
- expect(coordinator.getEntry(key)).toEqual({ status: "resolved", scope: "ready" });
251
-
252
- unsubscribe();
253
- const other = deferred<string>();
254
- coordinator.request({
255
- engineRef: engine,
256
- partition: "it",
257
- namespaces: ["default"],
258
- load: () => other.promise,
259
- });
260
- other.resolve("other");
261
- await vi.waitFor(() => expect(coordinator.revision).toBe(2));
262
- expect(listener).toHaveBeenCalledTimes(1);
263
- });
264
-
265
- it("logs rejected loads when getPromise is not awaited", async () => {
266
- const coordinator = createLoadCoordinator<string>();
267
- const pending = deferred<string>();
268
- const engine = {};
269
- const key = {
270
- engineRef: engine,
271
- partition: "en",
272
- namespaces: ["default"] as const,
273
- };
274
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => void 0);
275
-
276
- coordinator.ensure({ ...key, load: () => pending.promise });
277
-
278
- pending.reject(new Error("load failed"));
279
- await vi.waitFor(() => {
280
- expect(coordinator.getEntry(key)).toEqual({ status: "error", error: expect.any(Error) });
281
- });
282
-
283
- expect(consoleError).toHaveBeenCalledWith(
284
- "[i18n-react] namespace load failed:",
285
- expect.any(Error)
286
- );
287
-
288
- consoleError.mockRestore();
289
- });
290
-
291
- it("keeps an error entry and notifies subscribers on reject", async () => {
292
- const coordinator = createLoadCoordinator<string>();
293
- const pending = deferred<string>();
294
- const engine = {};
295
- const key = {
296
- engineRef: engine,
297
- partition: "en",
298
- namespaces: ["default"] as const,
299
- };
300
- const load = vi.fn(() => pending.promise);
301
- const listener = vi.fn();
302
- const failure = new Error("load failed");
303
-
304
- coordinator.subscribe(listener);
305
- coordinator.request({ ...key, load });
306
-
307
- expect(coordinator.getEntry(key)).toEqual({ status: "pending" });
308
-
309
- pending.reject(failure);
310
- await expect(coordinator.getPromise()).rejects.toThrow("load failed");
311
-
312
- expect(listener).toHaveBeenCalledTimes(1);
313
- expect(coordinator.revision).toBe(1);
314
- expect(coordinator.getEntry(key)).toEqual({ status: "error", error: failure });
315
- expect(coordinator.getResolvedScope()).toBeNull();
316
-
317
- // Deduped: settled error entry is not retried until retry().
318
- coordinator.request({ ...key, load });
319
- expect(load).toHaveBeenCalledTimes(1);
320
- expect(coordinator.getEntry(key)).toEqual({ status: "error", error: failure });
321
- });
322
-
323
- it("retry() evicts an error entry so the next ensure reloads", async () => {
324
- const coordinator = createLoadCoordinator<string>();
325
- const engine = {};
326
- const key = {
327
- engineRef: engine,
328
- partition: "en",
329
- namespaces: ["default"] as const,
330
- };
331
- const load = vi
332
- .fn()
333
- .mockImplementationOnce(() => Promise.reject(new Error("fail")))
334
- .mockImplementationOnce(() => Promise.resolve("ok"));
335
-
336
- coordinator.request({ ...key, load });
337
- await expect(coordinator.getPromise()).rejects.toThrow("fail");
338
- expect(coordinator.getEntry(key).status).toBe("error");
339
-
340
- coordinator.retry(key);
341
- expect(coordinator.getEntry(key)).toEqual({ status: "pending" });
342
-
343
- coordinator.request({ ...key, load });
344
- await expect(coordinator.getPromise()).resolves.toBe("ok");
345
- expect(load).toHaveBeenCalledTimes(2);
346
- expect(coordinator.getEntry(key)).toEqual({ status: "resolved", scope: "ok" });
347
- });
348
-
349
- it("distinguishes different engineRef objects via WeakMap ids", async () => {
350
- const coordinator = createLoadCoordinator<string>();
351
- const engineA = {};
352
- const engineB = {};
353
- const loadA = vi.fn(() => Promise.resolve("a"));
354
- const loadB = vi.fn(() => Promise.resolve("b"));
355
-
356
- coordinator.request({
357
- engineRef: engineA,
358
- partition: "en",
359
- namespaces: ["default"],
360
- load: loadA,
361
- });
362
- const promiseA = coordinator.getPromise();
363
- coordinator.request({
364
- engineRef: engineB,
365
- partition: "en",
366
- namespaces: ["default"],
367
- load: loadB,
368
- });
369
- const promiseB = coordinator.getPromise();
370
-
371
- expect(loadA).toHaveBeenCalledTimes(1);
372
- expect(loadB).toHaveBeenCalledTimes(1);
373
- await expect(promiseA).resolves.toBe("a");
374
- await expect(promiseB).resolves.toBe("b");
375
- expect(
376
- coordinator.getEntry({ engineRef: engineA, partition: "en", namespaces: ["default"] })
377
- ).toEqual({ status: "resolved", scope: "a" });
378
- expect(
379
- coordinator.getEntry({ engineRef: engineB, partition: "en", namespaces: ["default"] })
380
- ).toEqual({ status: "resolved", scope: "b" });
381
- });
382
-
383
- it("returns pending for unknown keys without using React use()", () => {
384
- const coordinator = createLoadCoordinator<string>();
385
- expect(
386
- coordinator.getEntry({
387
- engineRef: {},
388
- partition: "en",
389
- namespaces: ["missing"],
390
- })
391
- ).toEqual({ status: "pending" });
392
- expect(coordinator.revision).toBe(0);
393
- });
394
-
395
- it("integrates with handle split-by-locale loads", async () => {
396
- const defaultLoader = vi.fn(async (locale: string) =>
397
- locale === "it" ? defaultIt : defaultEn
398
- );
399
- const engine = new IcuTranslationProviderMulti<MultiSchema, TestMultiParams>({});
400
- const handle = createI18nHandle(engine, {
401
- namespaceLoaders: {
402
- default: defaultLoader,
403
- },
404
- });
405
- const load = () => handle.load({ namespaces: ["default"], locale: "it" });
406
- const coordinator = createLoadCoordinator<Awaited<ReturnType<typeof load>>>();
407
- const key = {
408
- engineRef: engine,
409
- partition: "it",
410
- namespaces: ["default"] as const,
411
- };
412
-
413
- coordinator.request({ ...key, load });
414
-
415
- const scope = await coordinator.getPromise();
416
- expect(defaultLoader).toHaveBeenCalledWith("it", { locale: "it" });
417
- expect(scope.t("default", "greeting")).toBe("Ciao");
418
- expect(coordinator.revision).toBe(1);
419
- expect(coordinator.getEntry(key).status).toBe("resolved");
420
- });
421
- });