@prosopo/locale 3.2.9 → 3.3.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.
Files changed (93) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +10 -9
  2. package/.turbo/turbo-build$colon$tsc.log +5 -5
  3. package/.turbo/turbo-build.log +17 -16
  4. package/CHANGELOG.md +24 -0
  5. package/dist/cjs/i18SharedOptions.cjs +1 -1
  6. package/dist/cjs/i18nBackend.cjs +4 -1
  7. package/dist/cjs/i18nFrontend.cjs +4 -1
  8. package/dist/cjs/loadI18next.cjs +41 -14
  9. package/dist/cjs/process.cjs +17 -0
  10. package/dist/cjs/translationKey.cjs +2 -0
  11. package/dist/i18SharedOptions.js +2 -1
  12. package/dist/i18SharedOptions.js.map +1 -1
  13. package/dist/i18nBackend.d.ts.map +1 -1
  14. package/dist/i18nBackend.js +4 -1
  15. package/dist/i18nBackend.js.map +1 -1
  16. package/dist/i18nFrontend.d.ts.map +1 -1
  17. package/dist/i18nFrontend.js +4 -1
  18. package/dist/i18nFrontend.js.map +1 -1
  19. package/dist/loadI18next.d.ts +1 -0
  20. package/dist/loadI18next.d.ts.map +1 -1
  21. package/dist/loadI18next.js +41 -15
  22. package/dist/loadI18next.js.map +1 -1
  23. package/dist/process.d.ts +2 -0
  24. package/dist/process.d.ts.map +1 -0
  25. package/dist/process.js +17 -0
  26. package/dist/process.js.map +1 -0
  27. package/dist/tests/i18SharedOptions.unit.test.d.ts +2 -0
  28. package/dist/tests/i18SharedOptions.unit.test.d.ts.map +1 -0
  29. package/dist/tests/i18SharedOptions.unit.test.js +91 -0
  30. package/dist/tests/i18SharedOptions.unit.test.js.map +1 -0
  31. package/dist/tests/i18nBackend.unit.test.d.ts +2 -0
  32. package/dist/tests/i18nBackend.unit.test.d.ts.map +1 -0
  33. package/dist/tests/i18nBackend.unit.test.js +153 -0
  34. package/dist/tests/i18nBackend.unit.test.js.map +1 -0
  35. package/dist/tests/i18nFrontend.unit.test.d.ts +2 -0
  36. package/dist/tests/i18nFrontend.unit.test.d.ts.map +1 -0
  37. package/dist/tests/i18nFrontend.unit.test.js +199 -0
  38. package/dist/tests/i18nFrontend.unit.test.js.map +1 -0
  39. package/dist/tests/i18nMiddleware.unit.test.d.ts +2 -0
  40. package/dist/tests/i18nMiddleware.unit.test.d.ts.map +1 -0
  41. package/dist/tests/i18nMiddleware.unit.test.js +80 -0
  42. package/dist/tests/i18nMiddleware.unit.test.js.map +1 -0
  43. package/dist/tests/loadI18next.unit.test.d.ts +2 -0
  44. package/dist/tests/loadI18next.unit.test.d.ts.map +1 -0
  45. package/dist/tests/loadI18next.unit.test.js +270 -0
  46. package/dist/tests/loadI18next.unit.test.js.map +1 -0
  47. package/dist/tests/locale.test-d.d.ts +2 -0
  48. package/dist/tests/locale.test-d.d.ts.map +1 -0
  49. package/dist/tests/locale.test-d.js +88 -0
  50. package/dist/tests/locale.test-d.js.map +1 -0
  51. package/dist/tests/process.unit.test.d.ts +2 -0
  52. package/dist/tests/process.unit.test.d.ts.map +1 -0
  53. package/dist/tests/process.unit.test.js +14 -0
  54. package/dist/tests/process.unit.test.js.map +1 -0
  55. package/dist/tests/translationKey.unit.test.d.ts +2 -0
  56. package/dist/tests/translationKey.unit.test.d.ts.map +1 -0
  57. package/dist/tests/translationKey.unit.test.js +108 -0
  58. package/dist/tests/translationKey.unit.test.js.map +1 -0
  59. package/dist/tests/translations.unit.test.d.ts +2 -0
  60. package/dist/tests/translations.unit.test.d.ts.map +1 -0
  61. package/dist/tests/translations.unit.test.js +74 -0
  62. package/dist/tests/translations.unit.test.js.map +1 -0
  63. package/dist/tests/useTranslation.unit.test.d.ts +2 -0
  64. package/dist/tests/useTranslation.unit.test.d.ts.map +1 -0
  65. package/dist/tests/useTranslation.unit.test.js +81 -0
  66. package/dist/tests/useTranslation.unit.test.js.map +1 -0
  67. package/dist/tests/util.unit.test.d.ts +2 -0
  68. package/dist/tests/util.unit.test.d.ts.map +1 -0
  69. package/dist/tests/util.unit.test.js +66 -0
  70. package/dist/tests/util.unit.test.js.map +1 -0
  71. package/dist/translationKey.d.ts +4 -0
  72. package/dist/translationKey.d.ts.map +1 -1
  73. package/dist/translationKey.js +2 -1
  74. package/dist/translationKey.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/i18SharedOptions.ts +4 -4
  77. package/src/i18nBackend.ts +16 -0
  78. package/src/i18nFrontend.ts +16 -0
  79. package/src/loadI18next.ts +83 -15
  80. package/src/process.ts +29 -0
  81. package/src/tests/i18SharedOptions.unit.test.ts +139 -0
  82. package/src/tests/i18nBackend.unit.test.ts +241 -0
  83. package/src/tests/i18nFrontend.unit.test.ts +302 -0
  84. package/src/tests/i18nMiddleware.unit.test.ts +140 -0
  85. package/src/tests/loadI18next.unit.test.ts +443 -0
  86. package/src/tests/locale.test-d.ts +156 -0
  87. package/src/tests/process.unit.test.ts +33 -0
  88. package/src/tests/translationKey.unit.test.ts +166 -0
  89. package/src/tests/translations.unit.test.ts +110 -0
  90. package/src/tests/useTranslation.unit.test.ts +134 -0
  91. package/src/tests/util.unit.test.ts +102 -0
  92. package/src/translationKey.ts +18 -3
  93. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,443 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { i18n } from "i18next";
16
+ import { afterEach, describe, expect, test, vi } from "vitest";
17
+
18
+ // loadI18next keeps a module-level singleton, so every test re-imports it
19
+ // under a fresh registry. The two i18n implementations are mocked out: they
20
+ // pull in i18next, its plugins and the filesystem, none of which this module's
21
+ // own logic depends on.
22
+
23
+ /** The slice of the i18next instance loadI18next actually touches. */
24
+ interface FakeI18n {
25
+ language: string;
26
+ changeLanguage: (lng: string) => Promise<unknown>;
27
+ }
28
+
29
+ /** One cast, confined here, so the mocked module keeps the real signature. */
30
+ const asI18n = (fake: FakeI18n): i18n => fake as unknown as i18n;
31
+
32
+ type InitializeI18n = (
33
+ callback?: (instance: i18n) => void,
34
+ lng?: string,
35
+ ) => i18n;
36
+
37
+ interface Harness {
38
+ instance: FakeI18n;
39
+ initialize: ReturnType<typeof vi.fn<InitializeI18n>>;
40
+ }
41
+
42
+ /**
43
+ * Install a mock for one of the two i18n implementations.
44
+ *
45
+ * `resolveWith` decides when the init callback fires — immediately (the
46
+ * "resources already loaded" case), asynchronously (the normal `loaded` event),
47
+ * or never (initialisation that stalls).
48
+ */
49
+ const install = (
50
+ module: "../i18nBackend.js" | "../i18nFrontend.js",
51
+ options: {
52
+ language?: string;
53
+ fire?: "sync" | "async" | "never";
54
+ changeLanguage?: (lng: string) => Promise<unknown>;
55
+ } = {},
56
+ ): Harness => {
57
+ const instance: FakeI18n = {
58
+ language: options.language ?? "en",
59
+ changeLanguage:
60
+ options.changeLanguage ??
61
+ (async (lng: string): Promise<unknown> => {
62
+ instance.language = lng;
63
+ return undefined;
64
+ }),
65
+ };
66
+
67
+ const initialize = vi.fn<InitializeI18n>((callback) => {
68
+ const fire = options.fire ?? "async";
69
+ if (fire === "sync") {
70
+ callback?.(asI18n(instance));
71
+ } else if (fire === "async") {
72
+ queueMicrotask(() => callback?.(asI18n(instance)));
73
+ }
74
+ return asI18n(instance);
75
+ });
76
+
77
+ vi.doMock(module, () => ({ default: initialize }));
78
+ return { instance, initialize };
79
+ };
80
+
81
+ const loadModule = async (): Promise<
82
+ (backend: boolean, lng?: string) => Promise<i18n>
83
+ > => {
84
+ const imported = await import("../loadI18next.js");
85
+ return imported.default;
86
+ };
87
+
88
+ afterEach(() => {
89
+ vi.useRealTimers();
90
+ vi.resetModules();
91
+ vi.doUnmock("../i18nBackend.js");
92
+ vi.doUnmock("../i18nFrontend.js");
93
+ vi.restoreAllMocks();
94
+ });
95
+
96
+ describe("loadI18next, backend", () => {
97
+ test("initialises once and resolves with the instance", async () => {
98
+ const { instance, initialize } = install("../i18nBackend.js");
99
+ const loadI18next = await loadModule();
100
+
101
+ const resolved = await loadI18next(true);
102
+
103
+ expect(initialize).toHaveBeenCalledTimes(1);
104
+ expect(resolved).toBe(asI18n(instance));
105
+ });
106
+
107
+ test("resolves from the callback, not from the return value", async () => {
108
+ // The distinction matters: the return value is available synchronously
109
+ // but the resources are not. Resolving early would hand back an
110
+ // instance that cannot translate anything yet.
111
+ const { initialize } = install("../i18nBackend.js", { fire: "never" });
112
+ const loadI18next = await loadModule();
113
+
114
+ let settled = false;
115
+ void loadI18next(true).then(() => {
116
+ settled = true;
117
+ });
118
+ // Long enough for the dynamic import to resolve and init to be called;
119
+ // the promise still must not settle, because the callback never fires.
120
+ await new Promise((resolve) => setTimeout(resolve, 20));
121
+
122
+ expect(initialize).toHaveBeenCalledTimes(1);
123
+ expect(settled).toBe(false);
124
+ });
125
+
126
+ test("reuses the singleton on a second call without re-initialising", async () => {
127
+ const { instance, initialize } = install("../i18nBackend.js");
128
+ const loadI18next = await loadModule();
129
+
130
+ const first = await loadI18next(true);
131
+ const second = await loadI18next(true);
132
+
133
+ expect(initialize).toHaveBeenCalledTimes(1);
134
+ expect(second).toBe(first);
135
+ expect(second).toBe(asI18n(instance));
136
+ });
137
+
138
+ test("ignores lng on the backend path — the server detects per request", async () => {
139
+ // Server-side language comes from the Accept-Language header via the
140
+ // middleware's detector, so pinning one language at init would be wrong.
141
+ const { instance, initialize } = install("../i18nBackend.js", {
142
+ language: "en",
143
+ });
144
+ const loadI18next = await loadModule();
145
+
146
+ await loadI18next(true, "fr");
147
+
148
+ expect(initialize.mock.calls[0]?.[1]).toBeUndefined();
149
+ expect(instance.language).toBe("en");
150
+ });
151
+
152
+ test("rejects, rather than hanging, when the module fails to load", async () => {
153
+ // A synchronous try/catch cannot see a rejected dynamic import. Without
154
+ // an explicit .catch the promise would stay pending forever and hang
155
+ // the server bootstrap that awaits it.
156
+ vi.doMock("../i18nBackend.js", () => {
157
+ throw new Error("backend module blew up");
158
+ });
159
+ const loadI18next = await loadModule();
160
+
161
+ // The assertion is that it settles at all. vitest rewraps a failing mock
162
+ // factory in its own error, so the message is not ours to match on — the
163
+ // behaviour under test is rejection instead of an indefinite hang.
164
+ await expect(loadI18next(true)).rejects.toThrow();
165
+ });
166
+ });
167
+
168
+ describe("loadI18next, frontend", () => {
169
+ test("initialises once and resolves with the instance", async () => {
170
+ const { instance, initialize } = install("../i18nFrontend.js");
171
+ const loadI18next = await loadModule();
172
+
173
+ const resolved = await loadI18next(false);
174
+
175
+ expect(initialize).toHaveBeenCalledTimes(1);
176
+ expect(resolved).toBe(asI18n(instance));
177
+ });
178
+
179
+ test("passes lng straight into init so browser detection is skipped", async () => {
180
+ const { initialize } = install("../i18nFrontend.js", { language: "fr" });
181
+ const loadI18next = await loadModule();
182
+
183
+ await loadI18next(false, "fr");
184
+
185
+ expect(initialize.mock.calls[0]?.[1]).toBe("fr");
186
+ });
187
+
188
+ test("does not call changeLanguage when init already produced the target", async () => {
189
+ // Passing lng at init means the resources are already the right ones;
190
+ // a redundant changeLanguage would trigger a second load and a visible
191
+ // re-render.
192
+ const changeLanguage = vi.fn(async (): Promise<unknown> => undefined);
193
+ install("../i18nFrontend.js", { language: "fr", changeLanguage });
194
+ const loadI18next = await loadModule();
195
+
196
+ await loadI18next(false, "fr");
197
+
198
+ expect(changeLanguage).not.toHaveBeenCalled();
199
+ });
200
+
201
+ test("does not call changeLanguage when no language was requested", async () => {
202
+ const changeLanguage = vi.fn(async (): Promise<unknown> => undefined);
203
+ install("../i18nFrontend.js", { language: "de", changeLanguage });
204
+ const loadI18next = await loadModule();
205
+
206
+ await loadI18next(false);
207
+
208
+ expect(changeLanguage).not.toHaveBeenCalled();
209
+ });
210
+
211
+ test("reconciles when init settled on a different language", async () => {
212
+ const changeLanguage = vi.fn(async (): Promise<unknown> => undefined);
213
+ install("../i18nFrontend.js", { language: "en", changeLanguage });
214
+ const loadI18next = await loadModule();
215
+
216
+ await loadI18next(false, "fr");
217
+
218
+ expect(changeLanguage).toHaveBeenCalledWith("fr");
219
+ });
220
+
221
+ test("reconciles a pre-existing singleton before resolving", async () => {
222
+ // The second widget on a page asks for a different language. Resolving
223
+ // without reconciling would render a flash of the first widget's
224
+ // language.
225
+ const { instance, initialize } = install("../i18nFrontend.js", {
226
+ language: "en",
227
+ });
228
+ const loadI18next = await loadModule();
229
+
230
+ await loadI18next(false);
231
+ const second = await loadI18next(false, "fr");
232
+
233
+ expect(initialize).toHaveBeenCalledTimes(1);
234
+ expect(instance.language).toBe("fr");
235
+ expect(second).toBe(asI18n(instance));
236
+ });
237
+
238
+ test("resolves the cached singleton unchanged when lng is omitted", async () => {
239
+ const changeLanguage = vi.fn(async (): Promise<unknown> => undefined);
240
+ install("../i18nFrontend.js", { language: "de", changeLanguage });
241
+ const loadI18next = await loadModule();
242
+
243
+ await loadI18next(false);
244
+ await loadI18next(false);
245
+
246
+ expect(changeLanguage).not.toHaveBeenCalled();
247
+ });
248
+
249
+ test("rejects, rather than hanging, when the module fails to load", async () => {
250
+ vi.doMock("../i18nFrontend.js", () => {
251
+ throw new Error("frontend module blew up");
252
+ });
253
+ const loadI18next = await loadModule();
254
+
255
+ // See the backend equivalent: rejection, not the message, is the point.
256
+ await expect(loadI18next(false)).rejects.toThrow();
257
+ });
258
+
259
+ test("rejects when changeLanguage fails during first-load reconciliation", async () => {
260
+ // changeLanguage hits the network for the target resources, so it can
261
+ // genuinely fail. The caller has to learn about it instead of waiting.
262
+ install("../i18nFrontend.js", {
263
+ language: "en",
264
+ changeLanguage: async (): Promise<unknown> => {
265
+ throw new Error("resource fetch failed");
266
+ },
267
+ });
268
+ const loadI18next = await loadModule();
269
+
270
+ await expect(loadI18next(false, "fr")).rejects.toThrow(
271
+ "resource fetch failed",
272
+ );
273
+ });
274
+
275
+ test("rejects when changeLanguage fails while reconciling the singleton", async () => {
276
+ let failing = false;
277
+ install("../i18nFrontend.js", {
278
+ language: "en",
279
+ changeLanguage: async (): Promise<unknown> => {
280
+ if (failing) {
281
+ throw new Error("resource fetch failed");
282
+ }
283
+ return undefined;
284
+ },
285
+ });
286
+ const loadI18next = await loadModule();
287
+
288
+ await loadI18next(false);
289
+ failing = true;
290
+
291
+ await expect(loadI18next(false, "fr")).rejects.toThrow(
292
+ "resource fetch failed",
293
+ );
294
+ });
295
+ });
296
+
297
+ describe("loadI18next, timeout", () => {
298
+ // Resolution is event-driven, and events are not guaranteed. Nothing above
299
+ // this module times it out: cli.ts calls it with a bare .then, so a pending
300
+ // promise there is a boot hang with no log line to explain it.
301
+
302
+ test("resolves with the degraded instance when `loaded` never fires", async () => {
303
+ // Resolve rather than reject: i18next renders the key itself for a
304
+ // missing resource, so the widget still appears, untranslated. A
305
+ // rejection is unhandled at both call sites and loses the widget
306
+ // entirely.
307
+ vi.useFakeTimers();
308
+ const { instance } = install("../i18nBackend.js", { fire: "never" });
309
+ const loadI18next = await loadModule();
310
+ const { I18N_LOAD_TIMEOUT_MS } = await import("../loadI18next.js");
311
+
312
+ const pending = loadI18next(true);
313
+ await vi.advanceTimersByTimeAsync(I18N_LOAD_TIMEOUT_MS);
314
+
315
+ await expect(pending).resolves.toBe(asI18n(instance));
316
+ });
317
+
318
+ test("does not fire early — the instance is not handed back before the deadline", async () => {
319
+ vi.useFakeTimers();
320
+ install("../i18nBackend.js", { fire: "never" });
321
+ const loadI18next = await loadModule();
322
+ const { I18N_LOAD_TIMEOUT_MS } = await import("../loadI18next.js");
323
+
324
+ let settled = false;
325
+ void loadI18next(true).then(() => {
326
+ settled = true;
327
+ });
328
+ await vi.advanceTimersByTimeAsync(I18N_LOAD_TIMEOUT_MS - 1);
329
+
330
+ expect(settled).toBe(false);
331
+ });
332
+
333
+ test("rejects on timeout when no instance was ever created", async () => {
334
+ // Nothing to degrade to, so there is no resolution that would be honest.
335
+ // Rejecting at least surfaces the failure instead of hanging.
336
+ vi.useFakeTimers();
337
+ vi.doMock("../i18nBackend.js", () => new Promise<never>(() => undefined));
338
+ const loadI18next = await loadModule();
339
+ const { I18N_LOAD_TIMEOUT_MS } = await import("../loadI18next.js");
340
+
341
+ // Assert before advancing: the handler has to be attached when the timer
342
+ // fires, or the rejection surfaces as an unhandled one.
343
+ const assertion = expect(loadI18next(true)).rejects.toThrow(
344
+ /did not load within/,
345
+ );
346
+ await vi.advanceTimersByTimeAsync(I18N_LOAD_TIMEOUT_MS);
347
+ await assertion;
348
+ });
349
+
350
+ test("a normal load clears the timer, so a late event cannot re-settle it", async () => {
351
+ // i18next emits `loaded` per namespace and again after changeLanguage.
352
+ // Without the one-shot latch a later emission would call resolve again
353
+ // and leave the timer armed.
354
+ vi.useFakeTimers();
355
+ let fire: ((instance: i18n) => void) | undefined;
356
+ const instance: FakeI18n = {
357
+ language: "en",
358
+ changeLanguage: async (): Promise<unknown> => undefined,
359
+ };
360
+ const initialize = vi.fn<InitializeI18n>((callback) => {
361
+ fire = callback;
362
+ return asI18n(instance);
363
+ });
364
+ vi.doMock("../i18nBackend.js", () => ({ default: initialize }));
365
+ const loadI18next = await loadModule();
366
+ const { I18N_LOAD_TIMEOUT_MS } = await import("../loadI18next.js");
367
+
368
+ const pending = loadI18next(true);
369
+ await vi.advanceTimersByTimeAsync(0);
370
+ fire?.(asI18n(instance));
371
+ await expect(pending).resolves.toBe(asI18n(instance));
372
+
373
+ // A second emission after the deadline must be inert.
374
+ fire?.(asI18n(instance));
375
+ await vi.advanceTimersByTimeAsync(I18N_LOAD_TIMEOUT_MS * 2);
376
+ await expect(pending).resolves.toBe(asI18n(instance));
377
+ });
378
+
379
+ test("a failure arriving after the timeout cannot turn a resolved promise into a rejection", async () => {
380
+ // The frontend path can still fail late: the init callback fires, and the
381
+ // changeLanguage it triggers rejects, long after the deadline handed back
382
+ // the degraded instance. Calling reject then would produce an unhandled
383
+ // rejection on an already-settled promise.
384
+ vi.useFakeTimers();
385
+ let fire: ((instance: i18n) => void) | undefined;
386
+ const instance: FakeI18n = {
387
+ language: "en",
388
+ changeLanguage: async (): Promise<unknown> => {
389
+ throw new Error("resource fetch failed");
390
+ },
391
+ };
392
+ const initialize = vi.fn<InitializeI18n>((callback) => {
393
+ fire = callback;
394
+ return asI18n(instance);
395
+ });
396
+ vi.doMock("../i18nFrontend.js", () => ({ default: initialize }));
397
+ const loadI18next = await loadModule();
398
+ const { I18N_LOAD_TIMEOUT_MS } = await import("../loadI18next.js");
399
+
400
+ const pending = loadI18next(false, "fr");
401
+ await vi.advanceTimersByTimeAsync(I18N_LOAD_TIMEOUT_MS);
402
+ await expect(pending).resolves.toBe(asI18n(instance));
403
+
404
+ fire?.(asI18n(instance));
405
+ await vi.advanceTimersByTimeAsync(0);
406
+ await expect(pending).resolves.toBe(asI18n(instance));
407
+ });
408
+
409
+ test("the deadline is long enough not to trip a healthy cold start", async () => {
410
+ const { I18N_LOAD_TIMEOUT_MS } = await import("../loadI18next.js");
411
+ expect(I18N_LOAD_TIMEOUT_MS).toBeGreaterThanOrEqual(5_000);
412
+ });
413
+ });
414
+
415
+ describe("loadI18next, singleton shared across both paths", () => {
416
+ // One module-level instance serves both entry points. A frontend caller
417
+ // arriving after a backend caller gets the backend instance and never
418
+ // initialises the frontend one. That is deliberate — there is only ever one
419
+ // i18next — but it means the *first* caller decides the configuration.
420
+ test("a frontend call after a backend call reuses the backend instance", async () => {
421
+ const backend = install("../i18nBackend.js", { language: "en" });
422
+ const frontend = install("../i18nFrontend.js", { language: "de" });
423
+ const loadI18next = await loadModule();
424
+
425
+ const first = await loadI18next(true);
426
+ const second = await loadI18next(false);
427
+
428
+ expect(backend.initialize).toHaveBeenCalledTimes(1);
429
+ expect(frontend.initialize).not.toHaveBeenCalled();
430
+ expect(second).toBe(first);
431
+ });
432
+
433
+ test("the later caller's language still wins via reconciliation", async () => {
434
+ const backend = install("../i18nBackend.js", { language: "en" });
435
+ install("../i18nFrontend.js");
436
+ const loadI18next = await loadModule();
437
+
438
+ await loadI18next(true);
439
+ await loadI18next(false, "fr");
440
+
441
+ expect(backend.instance.language).toBe("fr");
442
+ });
443
+ });
@@ -0,0 +1,156 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // Asserted against the package entrypoint, which is what consumers import — a
16
+ // barrel that stops re-exporting something fails here rather than at a
17
+ // downstream build.
18
+
19
+ import { assertType, describe, expectTypeOf, it } from "vitest";
20
+ import {
21
+ LanguageSchema,
22
+ Languages,
23
+ type Ti18n,
24
+ type TranslationKey,
25
+ TranslationKeysSchema,
26
+ i18nMiddleware,
27
+ isClientSide,
28
+ loadI18next,
29
+ useTranslation,
30
+ } from "../index.js";
31
+
32
+ describe("Languages", () => {
33
+ it("is a frozen const map of name to code, not a widened record", () => {
34
+ // `as const` is what lets LanguageSchema derive its enum. Widening this
35
+ // to Record<string, string> would silently turn the schema into a
36
+ // runtime-only constraint.
37
+ expectTypeOf(Languages.english).toEqualTypeOf<"en">();
38
+ expectTypeOf(Languages.chinese).toEqualTypeOf<"zh-CN">();
39
+ });
40
+
41
+ it("rejects assignment to a code", () => {
42
+ // @ts-expect-error Languages is const
43
+ Languages.english = "fr";
44
+ });
45
+
46
+ it("has no member for a language that is not shipped", () => {
47
+ // @ts-expect-error klingon is not a supported language
48
+ Languages.klingon;
49
+ });
50
+ });
51
+
52
+ describe("LanguageSchema", () => {
53
+ it("parses to a string", () => {
54
+ // The enum is built from a runtime `Object.values` cast, so the parsed
55
+ // type is string rather than the union of codes. Pinned deliberately:
56
+ // a consumer must not assume narrowing it does not get.
57
+ expectTypeOf(LanguageSchema.parse("en")).toEqualTypeOf<string>();
58
+ });
59
+
60
+ it("exposes .enum as a lookup of codes", () => {
61
+ expectTypeOf(LanguageSchema.enum).toExtend<Record<string, string>>();
62
+ });
63
+
64
+ it("accepts unknown input at the parse boundary", () => {
65
+ expectTypeOf(LanguageSchema.parse).toBeCallableWith("anything");
66
+ });
67
+ });
68
+
69
+ describe("TranslationKey", () => {
70
+ it("is inferred from the schema", () => {
71
+ expectTypeOf<TranslationKey>().toEqualTypeOf<
72
+ ReturnType<typeof TranslationKeysSchema.parse>
73
+ >();
74
+ });
75
+
76
+ it("accepts a key literal, so error call sites need no cast", () => {
77
+ // packages/common's ProsopoError takes TranslationKey and is called
78
+ // with literals throughout.
79
+ assertType<TranslationKey>("API.UNKNOWN");
80
+ });
81
+
82
+ it("exposes the derived keys as an array of options", () => {
83
+ // A non-empty tuple, which is what z.enum requires and what the mongoose
84
+ // schemas in types-database spread into their `enum` fields.
85
+ expectTypeOf(TranslationKeysSchema.options).toExtend<readonly string[]>();
86
+ });
87
+ });
88
+
89
+ describe("isClientSide", () => {
90
+ it("takes no arguments and returns a boolean", () => {
91
+ expectTypeOf(isClientSide).parameters.toEqualTypeOf<[]>();
92
+ expectTypeOf(isClientSide).returns.toEqualTypeOf<boolean>();
93
+ });
94
+ });
95
+
96
+ describe("loadI18next", () => {
97
+ it("requires the backend flag", () => {
98
+ expectTypeOf(loadI18next).toBeCallableWith(true);
99
+ expectTypeOf(loadI18next).toBeCallableWith(false, "fr");
100
+ });
101
+
102
+ it("rejects a call with no arguments", () => {
103
+ // @ts-expect-error the backend flag is required
104
+ loadI18next();
105
+ });
106
+
107
+ it("rejects a non-boolean first argument", () => {
108
+ // Catches the easy mistake of passing the language positionally.
109
+ // @ts-expect-error the first parameter is the backend flag
110
+ loadI18next("fr");
111
+ });
112
+
113
+ it("resolves to the i18next instance type the package re-exports", () => {
114
+ expectTypeOf(loadI18next).returns.toEqualTypeOf<Promise<Ti18n>>();
115
+ });
116
+ });
117
+
118
+ describe("Ti18n", () => {
119
+ it("carries the i18next surface consumers need", () => {
120
+ expectTypeOf<Ti18n>().toHaveProperty("t");
121
+ expectTypeOf<Ti18n>().toHaveProperty("language");
122
+ expectTypeOf<Ti18n>().toHaveProperty("changeLanguage");
123
+ });
124
+ });
125
+
126
+ describe("i18nMiddleware", () => {
127
+ it("takes handler options and resolves to an express handler", () => {
128
+ expectTypeOf(i18nMiddleware).toBeCallableWith({});
129
+ expectTypeOf(i18nMiddleware).returns.toExtend<Promise<unknown>>();
130
+ });
131
+
132
+ it("rejects an unknown option", () => {
133
+ // @ts-expect-error notAnOption is not part of HandleOptions
134
+ i18nMiddleware({ notAnOption: true });
135
+ });
136
+
137
+ it("rejects being called with no options", () => {
138
+ // @ts-expect-error options are required
139
+ i18nMiddleware();
140
+ });
141
+ });
142
+
143
+ describe("useTranslation", () => {
144
+ it("is callable with no options", () => {
145
+ expectTypeOf(useTranslation).toBeCallableWith();
146
+ });
147
+
148
+ it("returns a t function", () => {
149
+ expectTypeOf(useTranslation).returns.toHaveProperty("t");
150
+ });
151
+
152
+ it("rejects an unknown option", () => {
153
+ // @ts-expect-error notAnOption is not a UseTranslationOptions field
154
+ useTranslation({ notAnOption: 1 });
155
+ });
156
+ });
@@ -0,0 +1,33 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { describe, expect, test } from "vitest";
16
+ import { getProcess } from "../process.js";
17
+
18
+ describe("getProcess", () => {
19
+ // Under vitest there is always a real `process`, so only this arm is
20
+ // directly observable here. The undefined arm is what consumers mock, and
21
+ // i18SharedOptions.unit.test.ts exercises their behaviour against it.
22
+ test("returns the process global when one exists", () => {
23
+ expect(getProcess()).toBe(process);
24
+ });
25
+
26
+ test("exposes env, which is the only member consumers use", () => {
27
+ expect(getProcess()?.env).toBe(process.env);
28
+ });
29
+
30
+ test("is stable across calls rather than returning a copy", () => {
31
+ expect(getProcess()).toBe(getProcess());
32
+ });
33
+ });