@prosopo/locale 3.6.1 → 3.7.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 (144) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +8 -9
  2. package/.turbo/turbo-build$colon$tsc.log +6 -9
  3. package/.turbo/turbo-build.log +12 -16
  4. package/CHANGELOG.md +40 -0
  5. package/LICENSE +201 -0
  6. package/dist/cjs/direction.cjs +19 -0
  7. package/dist/cjs/errorMessage.cjs +15 -0
  8. package/dist/cjs/index.cjs +5 -0
  9. package/dist/cjs/locales/en/translation.cjs +23 -5
  10. package/dist/cjs/translationKey.cjs +4 -0
  11. package/dist/direction.d.ts +3 -0
  12. package/dist/direction.d.ts.map +1 -0
  13. package/dist/direction.js +19 -0
  14. package/dist/direction.js.map +1 -0
  15. package/dist/errorMessage.d.ts +7 -0
  16. package/dist/errorMessage.d.ts.map +1 -0
  17. package/dist/errorMessage.js +15 -0
  18. package/dist/errorMessage.js.map +1 -0
  19. package/dist/index.d.ts +5 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +4 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/locales/ar/translation.json +23 -5
  24. package/dist/locales/az/translation.json +23 -5
  25. package/dist/locales/cs/translation.json +23 -5
  26. package/dist/locales/de/translation.json +23 -5
  27. package/dist/locales/el/translation.json +23 -5
  28. package/dist/locales/en/translation.js +23 -5
  29. package/dist/locales/en/translation.json +23 -5
  30. package/dist/locales/es/translation.json +23 -5
  31. package/dist/locales/fi/translation.json +23 -5
  32. package/dist/locales/fr/translation.json +23 -5
  33. package/dist/locales/hi/translation.json +23 -5
  34. package/dist/locales/hu/translation.json +23 -5
  35. package/dist/locales/id/translation.json +23 -5
  36. package/dist/locales/it/translation.json +23 -5
  37. package/dist/locales/ja/translation.json +23 -5
  38. package/dist/locales/jv/translation.json +23 -5
  39. package/dist/locales/ko/translation.json +23 -5
  40. package/dist/locales/ml/translation.json +23 -5
  41. package/dist/locales/ms/translation.json +23 -5
  42. package/dist/locales/nl/translation.json +23 -5
  43. package/dist/locales/no/translation.json +23 -5
  44. package/dist/locales/pl/translation.json +23 -5
  45. package/dist/locales/pt/translation.json +23 -5
  46. package/dist/locales/pt-BR/translation.json +23 -5
  47. package/dist/locales/ro/translation.json +23 -5
  48. package/dist/locales/ru/translation.json +23 -5
  49. package/dist/locales/sr/translation.json +23 -5
  50. package/dist/locales/sv/translation.json +23 -5
  51. package/dist/locales/th/translation.json +23 -5
  52. package/dist/locales/tr/translation.json +23 -5
  53. package/dist/locales/uk/translation.json +23 -5
  54. package/dist/locales/vi/translation.json +23 -5
  55. package/dist/locales/zh-CN/translation.json +23 -5
  56. package/dist/tests/direction.unit.test.d.ts +2 -0
  57. package/dist/tests/direction.unit.test.d.ts.map +1 -0
  58. package/dist/tests/direction.unit.test.js +31 -0
  59. package/dist/tests/direction.unit.test.js.map +1 -0
  60. package/dist/tests/errorMessage.unit.test.d.ts +2 -0
  61. package/dist/tests/errorMessage.unit.test.d.ts.map +1 -0
  62. package/dist/tests/errorMessage.unit.test.js +49 -0
  63. package/dist/tests/errorMessage.unit.test.js.map +1 -0
  64. package/dist/tests/i18nFrontend.unit.test.js +22 -11
  65. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  66. package/dist/tests/locale.test-d.js +18 -3
  67. package/dist/tests/locale.test-d.js.map +1 -1
  68. package/dist/tests/thrownErrorKeys.unit.test.d.ts +2 -0
  69. package/dist/tests/thrownErrorKeys.unit.test.d.ts.map +1 -0
  70. package/dist/tests/thrownErrorKeys.unit.test.js +59 -0
  71. package/dist/tests/thrownErrorKeys.unit.test.js.map +1 -0
  72. package/dist/tests/translationKey.unit.test.js +13 -1
  73. package/dist/tests/translationKey.unit.test.js.map +1 -1
  74. package/dist/translationKey.d.ts +7 -1
  75. package/dist/translationKey.d.ts.map +1 -1
  76. package/dist/translationKey.js +4 -1
  77. package/dist/translationKey.js.map +1 -1
  78. package/dist/translator.d.ts +2 -1
  79. package/dist/translator.d.ts.map +1 -1
  80. package/dist/translator.js.map +1 -1
  81. package/dist/types.d.ts +2 -1
  82. package/dist/types.d.ts.map +1 -1
  83. package/package.json +65 -66
  84. package/src/i18SharedOptions.ts +0 -29
  85. package/src/i18nBackend.ts +0 -69
  86. package/src/i18nFrontend.ts +0 -267
  87. package/src/i18nMiddleware.ts +0 -35
  88. package/src/index.ts +0 -29
  89. package/src/loadI18next.ts +0 -136
  90. package/src/locales/ar/translation.json +0 -476
  91. package/src/locales/az/translation.json +0 -476
  92. package/src/locales/cs/translation.json +0 -476
  93. package/src/locales/de/translation.json +0 -476
  94. package/src/locales/el/translation.json +0 -476
  95. package/src/locales/en/translation.json +0 -476
  96. package/src/locales/es/translation.json +0 -476
  97. package/src/locales/fi/translation.json +0 -476
  98. package/src/locales/fr/translation.json +0 -476
  99. package/src/locales/hi/translation.json +0 -476
  100. package/src/locales/hu/translation.json +0 -476
  101. package/src/locales/id/translation.json +0 -476
  102. package/src/locales/it/translation.json +0 -476
  103. package/src/locales/ja/translation.json +0 -476
  104. package/src/locales/jv/translation.json +0 -476
  105. package/src/locales/ko/translation.json +0 -476
  106. package/src/locales/ml/translation.json +0 -476
  107. package/src/locales/ms/translation.json +0 -476
  108. package/src/locales/nl/translation.json +0 -476
  109. package/src/locales/no/translation.json +0 -476
  110. package/src/locales/pl/translation.json +0 -476
  111. package/src/locales/pt/translation.json +0 -476
  112. package/src/locales/pt-BR/translation.json +0 -476
  113. package/src/locales/ro/translation.json +0 -476
  114. package/src/locales/ru/translation.json +0 -476
  115. package/src/locales/sr/translation.json +0 -476
  116. package/src/locales/sv/translation.json +0 -476
  117. package/src/locales/th/translation.json +0 -476
  118. package/src/locales/tr/translation.json +0 -476
  119. package/src/locales/uk/translation.json +0 -476
  120. package/src/locales/vi/translation.json +0 -476
  121. package/src/locales/zh-CN/translation.json +0 -476
  122. package/src/process.ts +0 -29
  123. package/src/tests/i18SharedOptions.unit.test.ts +0 -139
  124. package/src/tests/i18nBackend.unit.test.ts +0 -241
  125. package/src/tests/i18nFrontend.unit.test.ts +0 -393
  126. package/src/tests/i18nMiddleware.unit.test.ts +0 -159
  127. package/src/tests/loadI18next.unit.test.ts +0 -443
  128. package/src/tests/locale.test-d.ts +0 -160
  129. package/src/tests/localePlaceholders.unit.test.ts +0 -81
  130. package/src/tests/locales.unit.test.ts +0 -79
  131. package/src/tests/process.unit.test.ts +0 -33
  132. package/src/tests/translationKey.unit.test.ts +0 -140
  133. package/src/tests/translations.unit.test.ts +0 -97
  134. package/src/tests/translator.unit.test.ts +0 -213
  135. package/src/tests/util.unit.test.ts +0 -102
  136. package/src/translationKey.ts +0 -64
  137. package/src/translations.ts +0 -55
  138. package/src/translator.ts +0 -68
  139. package/src/types.ts +0 -42
  140. package/src/util.ts +0 -25
  141. package/tsconfig.cjs.json +0 -21
  142. package/tsconfig.json +0 -22
  143. package/tsconfig.tsbuildinfo +0 -1
  144. package/tsconfig.types.json +0 -9
@@ -1,159 +0,0 @@
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 type { handle } from "i18next-http-middleware";
17
- import { afterEach, describe, expect, test, vi } from "vitest";
18
-
19
- type HandleOptions = NonNullable<Parameters<typeof handle>[1]>;
20
- type Handler = ReturnType<typeof handle>;
21
-
22
- const asI18n = (value: object): i18n => value as unknown as i18n;
23
-
24
- interface Harness {
25
- instance: i18n;
26
- handleCalls: [i18n, HandleOptions | undefined][];
27
- handler: Handler;
28
- /** True once loadI18next has resolved — handle() must not run before then. */
29
- loaded: () => boolean;
30
- }
31
-
32
- const load = async (options: {
33
- loadRejects?: boolean;
34
- }): Promise<{
35
- i18nMiddleware: (opts: HandleOptions) => Promise<Handler>;
36
- harness: Harness;
37
- }> => {
38
- vi.resetModules();
39
-
40
- const instance = asI18n({ name: "the singleton" });
41
- const handleCalls: [i18n, HandleOptions | undefined][] = [];
42
- const handler = ((): void => {}) as unknown as Handler;
43
- let loaded = false;
44
-
45
- // The middleware takes the i18next singleton directly: handle() needs the
46
- // real instance, not the narrow Ti18n loadI18next hands back.
47
- vi.doMock("i18next", () => ({ default: instance }));
48
-
49
- vi.doMock("i18next-http-middleware", () => ({
50
- handle: (i18next: i18n, opts?: HandleOptions): Handler => {
51
- handleCalls.push([i18next, opts]);
52
- return handler;
53
- },
54
- }));
55
- vi.doMock("../loadI18next.js", () => ({
56
- default: async (): Promise<i18n> => {
57
- if (options.loadRejects) {
58
- throw new Error("i18n failed to load");
59
- }
60
- loaded = true;
61
- return instance;
62
- },
63
- }));
64
-
65
- const imported = await import("../i18nMiddleware.js");
66
- return {
67
- i18nMiddleware: imported.default,
68
- harness: { instance, handleCalls, handler, loaded: (): boolean => loaded },
69
- };
70
- };
71
-
72
- afterEach(() => {
73
- vi.resetModules();
74
- vi.doUnmock("i18next-http-middleware");
75
- vi.doUnmock("../loadI18next.js");
76
- vi.doUnmock("i18next");
77
- });
78
-
79
- describe("i18nMiddleware", () => {
80
- test("builds the handler from the i18next singleton", async () => {
81
- const { i18nMiddleware, harness } = await load({});
82
-
83
- const handler = await i18nMiddleware({});
84
-
85
- expect(harness.handleCalls).toHaveLength(1);
86
- expect(harness.handleCalls[0]?.[0]).toBe(harness.instance);
87
- expect(handler).toBe(harness.handler);
88
- });
89
-
90
- test("waits for the resources before handing the instance over", async () => {
91
- // handle() reads the instance's language list when it is built, so an
92
- // uninitialised singleton would produce a middleware that detects nothing.
93
- const { i18nMiddleware, harness } = await load({});
94
-
95
- await i18nMiddleware({});
96
-
97
- expect(harness.loaded()).toBe(true);
98
- });
99
-
100
- test("forwards the caller's options", async () => {
101
- const { i18nMiddleware, harness } = await load({});
102
- const options: HandleOptions = { ignoreRoutes: ["/healthz"] };
103
-
104
- await i18nMiddleware(options);
105
-
106
- expect(harness.handleCalls[0]?.[1]).toEqual({ ignoreRoutes: ["/healthz"] });
107
- });
108
-
109
- test("copies the options rather than passing the caller's object through", async () => {
110
- // handle() keeps the object for the lifetime of the process, so sharing
111
- // it would let a later mutation by the caller silently change routing.
112
- const { i18nMiddleware, harness } = await load({});
113
- const options: HandleOptions = { ignoreRoutes: ["/healthz"] };
114
-
115
- await i18nMiddleware(options);
116
-
117
- expect(harness.handleCalls[0]?.[1]).not.toBe(options);
118
- });
119
-
120
- test("accepts an empty options object", async () => {
121
- const { i18nMiddleware, harness } = await load({});
122
- await i18nMiddleware({});
123
- expect(harness.handleCalls[0]?.[1]).toEqual({});
124
- });
125
-
126
- test("always requests the backend instance", async () => {
127
- // Middleware only ever runs on the server; asking for the frontend
128
- // instance would pull in the browser detector and http backend.
129
- const loadSpy = vi.fn(async (): Promise<i18n> => asI18n({}));
130
- vi.resetModules();
131
- vi.doMock("i18next-http-middleware", () => ({
132
- handle: (): Handler => ((): void => {}) as unknown as Handler,
133
- }));
134
- vi.doMock("../loadI18next.js", () => ({ default: loadSpy }));
135
-
136
- const imported = await import("../i18nMiddleware.js");
137
- await imported.default({});
138
-
139
- expect(loadSpy).toHaveBeenCalledWith(true);
140
- });
141
-
142
- test("propagates a failure to load i18next instead of returning a broken handler", async () => {
143
- // The caller mounts whatever this resolves to. Swallowing the error
144
- // would mount a handler over an uninitialised i18next and fail on the
145
- // first request instead of at boot.
146
- const { i18nMiddleware } = await load({ loadRejects: true });
147
-
148
- await expect(i18nMiddleware({})).rejects.toThrow("i18n failed to load");
149
- });
150
-
151
- test("returns a fresh handler per call", async () => {
152
- const { i18nMiddleware, harness } = await load({});
153
-
154
- await i18nMiddleware({});
155
- await i18nMiddleware({ ignoreRoutes: ["/x"] });
156
-
157
- expect(harness.handleCalls).toHaveLength(2);
158
- });
159
- });
@@ -1,443 +0,0 @@
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 { afterEach, describe, expect, test, vi } from "vitest";
16
- import type { Ti18n } from "../types.js";
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: one
20
- // fetches catalogues over the network, the other pulls in i18next and the
21
- // filesystem, and neither is what this module's own logic does.
22
-
23
- /** The slice of an i18n 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): Ti18n => fake as unknown as Ti18n;
31
-
32
- type InitializeI18n = (
33
- callback?: (instance: Ti18n) => void,
34
- lng?: string,
35
- ) => Ti18n;
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<Ti18n>
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: Ti18n) => 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: Ti18n) => 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
- });