@prosopo/locale 3.5.0 → 3.6.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 (76) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +15 -13
  2. package/.turbo/turbo-build$colon$tsc.log +8 -5
  3. package/.turbo/turbo-build.log +16 -14
  4. package/CHANGELOG.md +47 -0
  5. package/dist/cjs/i18SharedOptions.cjs +2 -2
  6. package/dist/cjs/i18nBackend.cjs +1 -1
  7. package/dist/cjs/i18nFrontend.cjs +169 -51
  8. package/dist/cjs/i18nMiddleware.cjs +5 -2
  9. package/dist/cjs/index.cjs +3 -2
  10. package/dist/cjs/translationKey.cjs +2 -3
  11. package/dist/cjs/translations.cjs +4 -5
  12. package/dist/cjs/translator.cjs +2 -3
  13. package/dist/i18SharedOptions.d.ts +2 -2
  14. package/dist/i18SharedOptions.js +3 -3
  15. package/dist/i18SharedOptions.js.map +1 -1
  16. package/dist/i18nBackend.js +1 -1
  17. package/dist/i18nFrontend.d.ts +2 -2
  18. package/dist/i18nFrontend.d.ts.map +1 -1
  19. package/dist/i18nFrontend.js +169 -45
  20. package/dist/i18nFrontend.js.map +1 -1
  21. package/dist/i18nMiddleware.d.ts.map +1 -1
  22. package/dist/i18nMiddleware.js +3 -1
  23. package/dist/i18nMiddleware.js.map +1 -1
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -3
  27. package/dist/index.js.map +1 -1
  28. package/dist/loadI18next.d.ts +2 -3
  29. package/dist/loadI18next.d.ts.map +1 -1
  30. package/dist/loadI18next.js.map +1 -1
  31. package/dist/tests/i18nFrontend.unit.test.js +218 -170
  32. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  33. package/dist/tests/i18nMiddleware.unit.test.js +11 -2
  34. package/dist/tests/i18nMiddleware.unit.test.js.map +1 -1
  35. package/dist/tests/loadI18next.unit.test.js.map +1 -1
  36. package/dist/tests/locale.test-d.js +15 -11
  37. package/dist/tests/locale.test-d.js.map +1 -1
  38. package/dist/tests/translationKey.unit.test.js +11 -33
  39. package/dist/tests/translationKey.unit.test.js.map +1 -1
  40. package/dist/tests/translations.unit.test.js +12 -22
  41. package/dist/tests/translations.unit.test.js.map +1 -1
  42. package/dist/translationKey.d.ts +2 -3
  43. package/dist/translationKey.d.ts.map +1 -1
  44. package/dist/translationKey.js +2 -3
  45. package/dist/translationKey.js.map +1 -1
  46. package/dist/translations.d.ts +3 -2
  47. package/dist/translations.d.ts.map +1 -1
  48. package/dist/translations.js +3 -3
  49. package/dist/translations.js.map +1 -1
  50. package/dist/translator.d.ts +2 -3
  51. package/dist/translator.d.ts.map +1 -1
  52. package/dist/translator.js +2 -3
  53. package/dist/translator.js.map +1 -1
  54. package/dist/types.d.ts +16 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +2 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +3 -7
  59. package/src/i18SharedOptions.ts +3 -3
  60. package/src/i18nFrontend.ts +240 -73
  61. package/src/i18nMiddleware.ts +6 -1
  62. package/src/index.ts +9 -3
  63. package/src/loadI18next.ts +7 -8
  64. package/src/tests/i18nFrontend.unit.test.ts +310 -216
  65. package/src/tests/i18nMiddleware.unit.test.ts +21 -2
  66. package/src/tests/loadI18next.unit.test.ts +11 -11
  67. package/src/tests/locale.test-d.ts +24 -23
  68. package/src/tests/translationKey.unit.test.ts +12 -38
  69. package/src/tests/translations.unit.test.ts +18 -31
  70. package/src/translationKey.ts +10 -9
  71. package/src/translations.ts +6 -5
  72. package/src/translator.ts +6 -7
  73. package/src/types.ts +42 -0
  74. package/tsconfig.cjs.json +3 -0
  75. package/tsconfig.json +3 -0
  76. package/tsconfig.tsbuildinfo +1 -1
@@ -12,288 +12,382 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import type { InitOptions, i18n } from "i18next";
16
- import { afterEach, describe, expect, test, vi } from "vitest";
15
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
16
+ import type { Ti18n } from "../types.js";
17
17
 
18
- // Every plugin is mocked: they reach for localStorage, cookies and the network,
19
- // none of which exists here and none of which this module's own logic needs.
18
+ // The module keeps a singleton, so every test re-imports it under a fresh
19
+ // registry. `fetch`, `localStorage`, `navigator.languages` and `document.cookie`
20
+ // are the module's four inputs and are all stubbed per test.
20
21
 
21
- type Listener = (...args: unknown[]) => void;
22
- type ResourceLoader = (language: string, namespace: string) => Promise<unknown>;
23
-
24
- interface FakeI18n {
25
- isInitialized: boolean;
26
- use: (plugin: unknown) => FakeI18n;
27
- init: (options: InitOptions) => FakeI18n;
28
- on: (event: string, listener: Listener) => void;
29
- }
22
+ type Catalogues = Record<string, Record<string, unknown>>;
30
23
 
31
24
  interface Harness {
32
- fake: FakeI18n;
33
- plugins: unknown[];
34
- initOptions: InitOptions[];
35
- /** The loader handed to resourcesToBackend — the in-memory fallback. */
36
- resourceLoaders: ResourceLoader[];
37
- detectorOptions: unknown[];
38
- emit: (event: string) => void;
25
+ initializeI18n: (callback?: (value: Ti18n) => void, lng?: string) => Ti18n;
26
+ /** Every URL passed to fetch, in call order. */
27
+ requested: string[];
28
+ cookie: () => string;
29
+ stored: () => Record<string, string>;
39
30
  }
40
31
 
41
- const load = async (
42
- isInitialized = false,
43
- ): Promise<{
44
- initializeI18n: (callback?: (value: i18n) => void, lng?: string) => i18n;
45
- harness: Harness;
46
- }> => {
32
+ const load = async (options: {
33
+ catalogues?: Catalogues;
34
+ languages?: string[];
35
+ cookie?: string;
36
+ stored?: string;
37
+ fetchFails?: boolean;
38
+ never?: boolean;
39
+ }): Promise<Harness> => {
47
40
  vi.resetModules();
48
41
 
49
- const plugins: unknown[] = [];
50
- const initOptions: InitOptions[] = [];
51
- const resourceLoaders: ResourceLoader[] = [];
52
- const detectorOptions: unknown[] = [];
53
- const listeners = new Map<string, Listener[]>();
54
-
55
- const fake: FakeI18n = {
56
- isInitialized,
57
- use: (plugin: unknown): FakeI18n => {
58
- plugins.push(plugin);
59
- return fake;
60
- },
61
- init: (options: InitOptions): FakeI18n => {
62
- initOptions.push(options);
63
- return fake;
64
- },
65
- on: (event: string, listener: Listener): void => {
66
- listeners.set(event, [...(listeners.get(event) ?? []), listener]);
67
- },
68
- };
69
-
70
- const harness: Harness = {
71
- fake,
72
- plugins,
73
- initOptions,
74
- resourceLoaders,
75
- detectorOptions,
76
- emit: (event: string): void => {
77
- for (const listener of listeners.get(event) ?? []) {
78
- listener();
42
+ const requested: string[] = [];
43
+ const store: Record<string, string> = options.stored
44
+ ? { i18nextLng: options.stored }
45
+ : {};
46
+ let cookie = options.cookie ? `i18next=${options.cookie}` : "";
47
+
48
+ vi.stubGlobal(
49
+ "fetch",
50
+ vi.fn(async (url: string): Promise<Response> => {
51
+ requested.push(url);
52
+ if (options.never) {
53
+ return new Promise<Response>(() => undefined);
79
54
  }
80
- },
81
- };
82
-
83
- vi.doMock("i18next", () => ({ default: fake }));
84
- vi.doMock("i18next-chained-backend", () => ({ default: "ChainedBackend" }));
85
- vi.doMock("i18next-http-backend", () => ({ default: "HttpBackend" }));
86
- vi.doMock("i18next-browser-languagedetector", () => ({
87
- default: class {
88
- constructor(_services: unknown, opts: unknown) {
89
- detectorOptions.push(opts);
55
+ if (options.fetchFails) {
56
+ throw new Error("network down");
90
57
  }
58
+ const language = url.split("/").at(-2) ?? "";
59
+ const body = options.catalogues?.[language];
60
+ return {
61
+ ok: body !== undefined,
62
+ json: async (): Promise<unknown> => body,
63
+ } as Response;
64
+ }),
65
+ );
66
+ vi.stubGlobal("localStorage", {
67
+ getItem: (key: string): string | null => store[key] ?? null,
68
+ setItem: (key: string, value: string): void => {
69
+ store[key] = value;
70
+ },
71
+ });
72
+ vi.stubGlobal("navigator", { languages: options.languages ?? ["en"] });
73
+ vi.stubGlobal("document", {
74
+ get cookie(): string {
75
+ return cookie;
91
76
  },
92
- }));
93
- vi.doMock("i18next-resources-to-backend", () => ({
94
- default: (loader: ResourceLoader): string => {
95
- resourceLoaders.push(loader);
96
- return "InMemoryBackend";
77
+ set cookie(value: string) {
78
+ cookie = value;
97
79
  },
98
- }));
80
+ });
99
81
 
100
82
  const imported = await import("../i18nFrontend.js");
101
- return { initializeI18n: imported.default, harness };
83
+ return {
84
+ initializeI18n: imported.default,
85
+ requested,
86
+ cookie: (): string => cookie,
87
+ stored: (): Record<string, string> => store,
88
+ };
102
89
  };
103
90
 
104
- const backendOf = (
105
- options: InitOptions | undefined,
106
- ): { backends?: unknown[]; backendOptions?: { loadPath?: string }[] } =>
107
- (options?.backend ?? {}) as {
108
- backends?: unknown[];
109
- backendOptions?: { loadPath?: string }[];
110
- };
91
+ /** Resolves with the instance the init callback hands back. */
92
+ const initialised = async (harness: Harness, lng?: string): Promise<Ti18n> =>
93
+ new Promise<Ti18n>((resolve) => {
94
+ harness.initializeI18n(resolve, lng);
95
+ });
111
96
 
112
97
  afterEach(() => {
98
+ vi.unstubAllGlobals();
113
99
  vi.resetModules();
114
- for (const module of [
115
- "i18next",
116
- "i18next-chained-backend",
117
- "i18next-http-backend",
118
- "i18next-browser-languagedetector",
119
- "i18next-resources-to-backend",
120
- ]) {
121
- vi.doUnmock(module);
122
- }
123
100
  });
124
101
 
125
- describe("i18nFrontend, first initialisation", () => {
126
- test("initialises i18next exactly once", async () => {
127
- const { initializeI18n, harness } = await load();
128
- initializeI18n();
129
- expect(harness.initOptions).toHaveLength(1);
102
+ describe("choosing a language", () => {
103
+ test("uses the language the caller asked for", async () => {
104
+ // The site owner's `data-language` is the single source of truth: reading
105
+ // the browser first would render a flash of the wrong language.
106
+ const harness = await load({
107
+ catalogues: { fr: {}, en: {} },
108
+ languages: ["de"],
109
+ });
110
+
111
+ const i18n = await initialised(harness, "fr");
112
+
113
+ expect(i18n.language).toBe("fr");
130
114
  });
131
115
 
132
- test("registers the chained backend and the language detector", async () => {
133
- const { initializeI18n, harness } = await load();
134
- initializeI18n();
135
- expect(harness.plugins).toHaveLength(2);
136
- expect(harness.plugins[0]).toBe("ChainedBackend");
116
+ test("prefers a cookie over the browser", async () => {
117
+ const harness = await load({
118
+ catalogues: { es: {}, en: {} },
119
+ cookie: "es",
120
+ stored: "de",
121
+ languages: ["it"],
122
+ });
123
+
124
+ expect((await initialised(harness)).language).toBe("es");
137
125
  });
138
126
 
139
- test("carries the shared options through to init", async () => {
140
- const { initializeI18n, harness } = await load();
141
- initializeI18n();
142
- const options = harness.initOptions[0];
143
- expect(options?.fallbackLng).toBe("en");
144
- expect(options?.nonExplicitSupportedLngs).toBe(false);
145
- expect(options?.supportedLngs).toContain("en");
127
+ test("prefers stored over the browser", async () => {
128
+ const harness = await load({
129
+ catalogues: { de: {}, en: {} },
130
+ stored: "de",
131
+ languages: ["it"],
132
+ });
133
+
134
+ expect((await initialised(harness)).language).toBe("de");
146
135
  });
147
136
 
148
- test("returns the i18next instance", async () => {
149
- const { initializeI18n, harness } = await load();
150
- expect(initializeI18n()).toBe(harness.fake);
137
+ test("falls back to the browser when nothing is remembered", async () => {
138
+ const harness = await load({
139
+ catalogues: { it: {}, en: {} },
140
+ languages: ["it"],
141
+ });
142
+
143
+ expect((await initialised(harness)).language).toBe("it");
144
+ });
145
+
146
+ test("resolves a regional tag to its base language", async () => {
147
+ // Browsers report en-GB, pt-PT and so on; only the base languages (plus
148
+ // the handful of regional catalogues) are shipped.
149
+ const harness = await load({
150
+ catalogues: { en: {} },
151
+ languages: ["de-AT"],
152
+ });
153
+
154
+ expect((await initialised(harness)).language).toBe("de");
155
+ });
156
+
157
+ test("ignores a language with no catalogue", async () => {
158
+ const harness = await load({ catalogues: { en: {} }, languages: ["xx"] });
159
+
160
+ expect((await initialised(harness)).language).toBe("en");
161
+ });
162
+
163
+ test("remembers the language in both cookie and storage", async () => {
164
+ // Kept from the i18next language detector: a visitor who has picked a
165
+ // language keeps it across pages that embed the widget.
166
+ const harness = await load({ catalogues: { fr: {}, en: {} } });
167
+
168
+ await initialised(harness, "fr");
169
+
170
+ expect(harness.stored().i18nextLng).toBe("fr");
171
+ expect(harness.cookie()).toContain("i18next=fr");
151
172
  });
152
173
  });
153
174
 
154
- describe("i18nFrontend language selection", () => {
155
- // The whole point of threading lng through init: with it set, i18next never
156
- // consults the browser, so no child component can render against a
157
- // detected language before an effect corrects it.
158
- test("pins lng when the site owner supplied one", async () => {
159
- const { initializeI18n, harness } = await load();
160
- initializeI18n(undefined, "fr");
161
- expect(harness.initOptions[0]?.lng).toBe("fr");
175
+ describe("fetching catalogues", () => {
176
+ test("loads the active language from the directory the module came from", async () => {
177
+ const harness = await load({ catalogues: { fr: {}, en: {} } });
178
+
179
+ await initialised(harness, "fr");
180
+
181
+ expect(
182
+ harness.requested.some((url) =>
183
+ url.endsWith("/locales/fr/translation.json"),
184
+ ),
185
+ ).toBe(true);
162
186
  });
163
187
 
164
- test("omits lng entirely when none was supplied, leaving detection on", async () => {
165
- const { initializeI18n, harness } = await load();
166
- initializeI18n();
167
- expect(harness.initOptions[0]).not.toHaveProperty("lng");
188
+ test("loads English alongside it, for keys a translation is missing", async () => {
189
+ const harness = await load({ catalogues: { fr: {}, en: {} } });
190
+
191
+ await initialised(harness, "fr");
192
+
193
+ expect(harness.requested).toHaveLength(2);
194
+ expect(
195
+ harness.requested.some((url) =>
196
+ url.endsWith("/locales/en/translation.json"),
197
+ ),
198
+ ).toBe(true);
168
199
  });
169
200
 
170
- test("treats the empty string as no language rather than pinning it", async () => {
171
- // An empty `lng` would make i18next resolve nothing at all; falling back
172
- // to detection is the only sane reading of a blank config value.
173
- const { initializeI18n, harness } = await load();
174
- initializeI18n(undefined, "");
175
- expect(harness.initOptions[0]).not.toHaveProperty("lng");
201
+ test("fetches once when the active language is already English", async () => {
202
+ const harness = await load({ catalogues: { en: {} } });
203
+
204
+ await initialised(harness, "en");
205
+
206
+ expect(harness.requested).toHaveLength(1);
176
207
  });
208
+ });
177
209
 
178
- test("configures the detector to prefer a stored choice over the browser", async () => {
179
- const { initializeI18n, harness } = await load();
180
- initializeI18n();
181
- expect(harness.detectorOptions[0]).toMatchObject({
182
- order: ["cookie", "localStorage", "navigator"],
183
- lookupQuerystring: "lng",
210
+ describe("translating", () => {
211
+ test("reads a nested key by its dotted path", async () => {
212
+ const harness = await load({
213
+ catalogues: { en: { WIDGET: { I_AM_HUMAN: "I am human" } } },
184
214
  });
215
+
216
+ expect((await initialised(harness)).t("WIDGET.I_AM_HUMAN")).toBe(
217
+ "I am human",
218
+ );
185
219
  });
186
220
 
187
- test("persists the detected language to localStorage and a cookie", async () => {
188
- const { initializeI18n, harness } = await load();
189
- initializeI18n();
190
- expect(harness.detectorOptions[0]).toMatchObject({
191
- caches: ["localStorage", "cookie"],
221
+ test("substitutes interpolation values", async () => {
222
+ const harness = await load({
223
+ catalogues: { en: { POSITION: "{{x}} across, {{y}} down" } },
192
224
  });
225
+
226
+ expect((await initialised(harness)).t("POSITION", { x: 10, y: 20 })).toBe(
227
+ "10 across, 20 down",
228
+ );
193
229
  });
194
- });
195
230
 
196
- describe("i18nFrontend resource loading", () => {
197
- test("chains an in-memory fallback behind the http backend", async () => {
198
- // If the http load fails — a CDN blip, an offline widget — the bundled
199
- // resources still answer, so the widget renders text rather than keys.
200
- const { initializeI18n, harness } = await load();
201
- initializeI18n();
202
- const backend = backendOf(harness.initOptions[0]);
203
- expect(backend.backends).toEqual(["HttpBackend", "InMemoryBackend"]);
231
+ test("falls back to English when the translation lacks the key", async () => {
232
+ const harness = await load({
233
+ catalogues: { fr: { KEPT: "gardé" }, en: { KEPT: "kept", ONLY: "only" } },
234
+ });
235
+ const i18n = await initialised(harness, "fr");
236
+
237
+ expect(i18n.t("KEPT")).toBe("gardé");
238
+ expect(i18n.t("ONLY")).toBe("only");
204
239
  });
205
240
 
206
- test("gives the http backend a templated load path", async () => {
207
- const { initializeI18n, harness } = await load();
208
- initializeI18n();
209
- const backend = backendOf(harness.initOptions[0]);
210
- const loadPath = backend.backendOptions?.[0]?.loadPath;
211
- expect(loadPath).toContain("/locales/{{lng}}/{{ns}}.json");
241
+ test("falls back to the caller's default when no catalogue has the key", async () => {
242
+ const harness = await load({ catalogues: { en: {} } });
243
+
244
+ expect(
245
+ (await initialised(harness)).t("WIDGET.CHECKING", {
246
+ defaultValue: "Checking that you are human",
247
+ }),
248
+ ).toBe("Checking that you are human");
212
249
  });
213
250
 
214
- test("derives the load path from the module URL, not the page URL", async () => {
215
- // The widget is embedded on arbitrary sites, so a page-relative path
216
- // would resolve against the host site and 404.
217
- const { initializeI18n, harness } = await load();
218
- initializeI18n();
219
- const loadPath = backendOf(harness.initOptions[0]).backendOptions?.[0]
220
- ?.loadPath;
221
- // Absolute, with a scheme — file:// here, https:// once bundled and
222
- // served. What matters is that it is not relative.
223
- expect(loadPath).toMatch(/^[a-z]+:\/\//);
224
- // The module's own filename must have been stripped before the locales
225
- // segment is appended, or the path would nest under the script itself.
226
- expect(loadPath).not.toContain("i18nFrontend");
251
+ test("interpolates the default too", async () => {
252
+ const harness = await load({ catalogues: { en: {} } });
253
+
254
+ expect(
255
+ (await initialised(harness)).t("POSITION", {
256
+ defaultValue: "{{x}} across",
257
+ x: 7,
258
+ }),
259
+ ).toBe("7 across");
227
260
  });
228
261
 
229
- test("hands resourcesToBackend a loader that resolves a language and namespace", async () => {
230
- const { initializeI18n, harness } = await load();
231
- initializeI18n();
232
- const loader = harness.resourceLoaders[0];
233
- expect(loader).toBeTypeOf("function");
234
- // Invoked for its import, whose success depends on the bundler rather
235
- // than on this module; only the call shape is this module's contract.
236
- await expect(
237
- Promise.resolve(loader?.("en", "translation")).catch(() => undefined),
238
- ).resolves.not.toThrow();
262
+ test("falls back to the key when there is no default either", async () => {
263
+ const harness = await load({ catalogues: { en: {} } });
264
+
265
+ expect((await initialised(harness)).t("WIDGET.MISSING")).toBe(
266
+ "WIDGET.MISSING",
267
+ );
268
+ });
269
+
270
+ test("leaves a placeholder alone when no value was supplied for it", async () => {
271
+ const harness = await load({
272
+ catalogues: { en: { GREET: "hello {{name}}" } },
273
+ });
274
+
275
+ expect((await initialised(harness)).t("GREET", { other: 1 })).toBe(
276
+ "hello {{name}}",
277
+ );
239
278
  });
240
279
  });
241
280
 
242
- describe("i18nFrontend callback", () => {
243
- test("defers the callback until resources have loaded", async () => {
244
- const callback = vi.fn();
245
- const { initializeI18n, harness } = await load();
281
+ describe("when the catalogue cannot be fetched", () => {
282
+ test("still initialises, so the widget renders untranslated", async () => {
283
+ // Untranslated beats absent: a widget whose label never arrives is a
284
+ // widget the visitor cannot use.
285
+ const harness = await load({ catalogues: {}, fetchFails: true });
246
286
 
247
- initializeI18n(callback);
248
- expect(callback).not.toHaveBeenCalled();
287
+ const i18n = await initialised(harness);
249
288
 
250
- harness.emit("loaded");
251
- expect(callback).toHaveBeenCalledWith(harness.fake);
289
+ expect(i18n.isInitialized).toBe(true);
290
+ expect(i18n.t("WIDGET.CHECKING", { defaultValue: "Checking" })).toBe(
291
+ "Checking",
292
+ );
252
293
  });
253
294
 
254
- test("fires the callback once, however often `loaded` is emitted", async () => {
255
- // i18next emits `loaded` per namespace and again after changeLanguage,
256
- // so an unguarded listener would repeat whatever side effect the caller
257
- // attached to it.
258
- const callback = vi.fn();
259
- const { initializeI18n, harness } = await load();
295
+ test("reports the namespace as loaded so callers stop waiting", async () => {
296
+ const harness = await load({ catalogues: {}, fetchFails: true });
260
297
 
261
- initializeI18n(callback);
262
- harness.emit("loaded");
263
- harness.emit("loaded");
264
- expect(callback).toHaveBeenCalledTimes(1);
298
+ expect((await initialised(harness)).hasLoadedNamespace("translation")).toBe(
299
+ true,
300
+ );
265
301
  });
302
+ });
266
303
 
267
- test("tolerates being called with no callback", async () => {
268
- const { initializeI18n, harness } = await load();
269
- initializeI18n();
270
- expect(() => harness.emit("loaded")).not.toThrow();
304
+ describe("switching language", () => {
305
+ test("loads the new catalogue and translates against it", async () => {
306
+ const harness = await load({
307
+ catalogues: { en: { YES: "yes" }, de: { YES: "ja" } },
308
+ });
309
+ const i18n = await initialised(harness, "en");
310
+
311
+ await i18n.changeLanguage("de");
312
+
313
+ expect(i18n.language).toBe("de");
314
+ expect(i18n.t("YES")).toBe("ja");
271
315
  });
272
316
 
273
- // The `loaded` listener is only registered on the init path, so a second
274
- // widget mounting after the first would otherwise wait on an event that has
275
- // already fired and will not fire again.
276
- test("fires the callback immediately when i18next is already initialised", async () => {
277
- const callback = vi.fn();
278
- const { initializeI18n, harness } = await load(true);
317
+ test("notifies subscribers", async () => {
318
+ const harness = await load({ catalogues: { en: {}, de: {} } });
319
+ const i18n = await initialised(harness, "en");
320
+ const listener = vi.fn();
321
+ i18n.on("languageChanged", listener);
322
+
323
+ await i18n.changeLanguage("de");
279
324
 
280
- const returned = initializeI18n(callback);
325
+ expect(listener).toHaveBeenCalled();
326
+ });
327
+
328
+ test("does nothing when the language is already active", async () => {
329
+ const harness = await load({ catalogues: { en: {} } });
330
+ const i18n = await initialised(harness, "en");
331
+ const before = harness.requested.length;
332
+
333
+ await i18n.changeLanguage("en");
334
+
335
+ expect(harness.requested).toHaveLength(before);
336
+ });
337
+
338
+ test("unsubscribed listeners stop being called", async () => {
339
+ const harness = await load({ catalogues: { en: {}, de: {} } });
340
+ const i18n = await initialised(harness, "en");
341
+ const listener = vi.fn();
342
+ i18n.on("languageChanged", listener);
343
+ i18n.off("languageChanged", listener);
344
+
345
+ await i18n.changeLanguage("de");
346
+
347
+ expect(listener).not.toHaveBeenCalled();
348
+ });
349
+ });
350
+
351
+ describe("the singleton", () => {
352
+ test("a second call reuses the instance rather than refetching", async () => {
353
+ // Every widget on the page shares one catalogue fetch.
354
+ const harness = await load({ catalogues: { en: {} } });
355
+ const first = await initialised(harness);
356
+ const before = harness.requested.length;
357
+
358
+ const second = harness.initializeI18n();
359
+
360
+ expect(second).toBe(first);
361
+ expect(harness.requested).toHaveLength(before);
362
+ });
363
+
364
+ test("a second call still fires its callback", async () => {
365
+ // The one-shot callback is only armed on first init, so without this
366
+ // loadI18next() would never settle for the second widget.
367
+ const harness = await load({ catalogues: { en: {} } });
368
+ await initialised(harness);
369
+
370
+ const callback = vi.fn();
371
+ harness.initializeI18n(callback);
281
372
 
282
- expect(harness.initOptions).toHaveLength(0);
283
373
  expect(callback).toHaveBeenCalledTimes(1);
284
- expect(callback).toHaveBeenCalledWith(harness.fake);
285
- expect(returned).toBe(harness.fake);
374
+ });
375
+ });
376
+
377
+ describe("before the catalogue arrives", () => {
378
+ let harness: Harness;
379
+
380
+ beforeEach(async () => {
381
+ harness = await load({ never: true });
286
382
  });
287
383
 
288
- test("registers no plugins when already initialised", async () => {
289
- const { initializeI18n, harness } = await load(true);
290
- initializeI18n();
291
- expect(harness.plugins).toHaveLength(0);
384
+ test("reports itself uninitialised", () => {
385
+ expect(harness.initializeI18n().isInitialized).toBe(false);
292
386
  });
293
387
 
294
- test("ignores lng when already initialised — the caller reconciles instead", async () => {
295
- const { initializeI18n, harness } = await load(true);
296
- initializeI18n(undefined, "fr");
297
- expect(harness.initOptions).toHaveLength(0);
388
+ test("reports the namespace as not loaded", () => {
389
+ expect(harness.initializeI18n().hasLoadedNamespace("translation")).toBe(
390
+ false,
391
+ );
298
392
  });
299
393
  });