@prosopo/locale 3.4.3 → 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 (158) 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 +17 -15
  4. package/CHANGELOG.md +74 -0
  5. package/dist/cjs/i18SharedOptions.cjs +2 -2
  6. package/dist/cjs/i18nBackend.cjs +1 -1
  7. package/dist/cjs/i18nFrontend.cjs +167 -53
  8. package/dist/cjs/i18nMiddleware.cjs +5 -2
  9. package/dist/cjs/index.cjs +5 -4
  10. package/dist/cjs/locales/en/translation.cjs +1 -0
  11. package/dist/cjs/translationKey.cjs +2 -3
  12. package/dist/cjs/translations.cjs +4 -5
  13. package/dist/cjs/translator.cjs +29 -0
  14. package/dist/i18SharedOptions.d.ts +2 -2
  15. package/dist/i18SharedOptions.js +3 -3
  16. package/dist/i18SharedOptions.js.map +1 -1
  17. package/dist/i18nBackend.js +1 -1
  18. package/dist/i18nFrontend.d.ts +2 -2
  19. package/dist/i18nFrontend.d.ts.map +1 -1
  20. package/dist/i18nFrontend.js +167 -47
  21. package/dist/i18nFrontend.js.map +1 -1
  22. package/dist/i18nMiddleware.d.ts.map +1 -1
  23. package/dist/i18nMiddleware.js +3 -1
  24. package/dist/i18nMiddleware.js.map +1 -1
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -4
  28. package/dist/index.js.map +1 -1
  29. package/dist/loadI18next.d.ts +2 -3
  30. package/dist/loadI18next.d.ts.map +1 -1
  31. package/dist/loadI18next.js.map +1 -1
  32. package/dist/locales/ar/translation.json +1 -0
  33. package/dist/locales/az/translation.json +1 -0
  34. package/dist/locales/cs/translation.json +1 -0
  35. package/dist/locales/de/translation.json +1 -0
  36. package/dist/locales/el/translation.json +1 -0
  37. package/dist/locales/en/translation.js +1 -0
  38. package/dist/locales/en/translation.json +1 -0
  39. package/dist/locales/es/translation.json +1 -0
  40. package/dist/locales/fi/translation.json +1 -0
  41. package/dist/locales/fr/translation.json +1 -0
  42. package/dist/locales/hi/translation.json +1 -0
  43. package/dist/locales/hu/translation.json +1 -0
  44. package/dist/locales/id/translation.json +1 -0
  45. package/dist/locales/it/translation.json +1 -0
  46. package/dist/locales/ja/translation.json +1 -0
  47. package/dist/locales/jv/translation.json +1 -0
  48. package/dist/locales/ko/translation.json +1 -0
  49. package/dist/locales/ml/translation.json +1 -0
  50. package/dist/locales/ms/translation.json +1 -0
  51. package/dist/locales/nl/translation.json +1 -0
  52. package/dist/locales/no/translation.json +1 -0
  53. package/dist/locales/pl/translation.json +1 -0
  54. package/dist/locales/pt/translation.json +1 -0
  55. package/dist/locales/pt-BR/translation.json +1 -0
  56. package/dist/locales/ro/translation.json +1 -0
  57. package/dist/locales/ru/translation.json +1 -0
  58. package/dist/locales/sr/translation.json +1 -0
  59. package/dist/locales/sv/translation.json +1 -0
  60. package/dist/locales/th/translation.json +1 -0
  61. package/dist/locales/tr/translation.json +1 -0
  62. package/dist/locales/uk/translation.json +1 -0
  63. package/dist/locales/vi/translation.json +1 -0
  64. package/dist/locales/zh-CN/translation.json +1 -0
  65. package/dist/tests/i18nFrontend.unit.test.js +218 -173
  66. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  67. package/dist/tests/i18nMiddleware.unit.test.js +11 -2
  68. package/dist/tests/i18nMiddleware.unit.test.js.map +1 -1
  69. package/dist/tests/loadI18next.unit.test.js.map +1 -1
  70. package/dist/tests/locale.test-d.js +27 -20
  71. package/dist/tests/locale.test-d.js.map +1 -1
  72. package/dist/tests/translationKey.unit.test.js +11 -33
  73. package/dist/tests/translationKey.unit.test.js.map +1 -1
  74. package/dist/tests/translations.unit.test.js +12 -22
  75. package/dist/tests/translations.unit.test.js.map +1 -1
  76. package/dist/tests/translator.unit.test.d.ts +2 -0
  77. package/dist/tests/translator.unit.test.d.ts.map +1 -0
  78. package/dist/tests/translator.unit.test.js +134 -0
  79. package/dist/tests/translator.unit.test.js.map +1 -0
  80. package/dist/translationKey.d.ts +2 -3
  81. package/dist/translationKey.d.ts.map +1 -1
  82. package/dist/translationKey.js +2 -3
  83. package/dist/translationKey.js.map +1 -1
  84. package/dist/translations.d.ts +3 -2
  85. package/dist/translations.d.ts.map +1 -1
  86. package/dist/translations.js +3 -3
  87. package/dist/translations.js.map +1 -1
  88. package/dist/translator.d.ts +9 -0
  89. package/dist/translator.d.ts.map +1 -0
  90. package/dist/translator.js +29 -0
  91. package/dist/translator.js.map +1 -0
  92. package/dist/types.d.ts +16 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +2 -0
  95. package/dist/types.js.map +1 -0
  96. package/package.json +4 -9
  97. package/src/i18SharedOptions.ts +3 -3
  98. package/src/i18nFrontend.ts +240 -78
  99. package/src/i18nMiddleware.ts +6 -1
  100. package/src/index.ts +11 -4
  101. package/src/loadI18next.ts +8 -9
  102. package/src/locales/ar/translation.json +1 -0
  103. package/src/locales/az/translation.json +1 -0
  104. package/src/locales/cs/translation.json +1 -0
  105. package/src/locales/de/translation.json +1 -0
  106. package/src/locales/el/translation.json +1 -0
  107. package/src/locales/en/translation.json +1 -0
  108. package/src/locales/es/translation.json +1 -0
  109. package/src/locales/fi/translation.json +1 -0
  110. package/src/locales/fr/translation.json +1 -0
  111. package/src/locales/hi/translation.json +1 -0
  112. package/src/locales/hu/translation.json +1 -0
  113. package/src/locales/id/translation.json +1 -0
  114. package/src/locales/it/translation.json +1 -0
  115. package/src/locales/ja/translation.json +1 -0
  116. package/src/locales/jv/translation.json +1 -0
  117. package/src/locales/ko/translation.json +1 -0
  118. package/src/locales/ml/translation.json +1 -0
  119. package/src/locales/ms/translation.json +1 -0
  120. package/src/locales/nl/translation.json +1 -0
  121. package/src/locales/no/translation.json +1 -0
  122. package/src/locales/pl/translation.json +1 -0
  123. package/src/locales/pt/translation.json +1 -0
  124. package/src/locales/pt-BR/translation.json +1 -0
  125. package/src/locales/ro/translation.json +1 -0
  126. package/src/locales/ru/translation.json +1 -0
  127. package/src/locales/sr/translation.json +1 -0
  128. package/src/locales/sv/translation.json +1 -0
  129. package/src/locales/th/translation.json +1 -0
  130. package/src/locales/tr/translation.json +1 -0
  131. package/src/locales/uk/translation.json +1 -0
  132. package/src/locales/vi/translation.json +1 -0
  133. package/src/locales/zh-CN/translation.json +1 -0
  134. package/src/tests/i18nFrontend.unit.test.ts +310 -219
  135. package/src/tests/i18nMiddleware.unit.test.ts +21 -2
  136. package/src/tests/loadI18next.unit.test.ts +11 -11
  137. package/src/tests/locale.test-d.ts +36 -32
  138. package/src/tests/translationKey.unit.test.ts +12 -38
  139. package/src/tests/translations.unit.test.ts +18 -31
  140. package/src/tests/translator.unit.test.ts +213 -0
  141. package/src/translationKey.ts +10 -9
  142. package/src/translations.ts +6 -5
  143. package/src/translator.ts +68 -0
  144. package/src/types.ts +42 -0
  145. package/tsconfig.cjs.json +3 -0
  146. package/tsconfig.json +3 -0
  147. package/tsconfig.tsbuildinfo +1 -1
  148. package/dist/cjs/useTranslation.cjs +0 -12
  149. package/dist/tests/useTranslation.unit.test.d.ts +0 -2
  150. package/dist/tests/useTranslation.unit.test.d.ts.map +0 -1
  151. package/dist/tests/useTranslation.unit.test.js +0 -81
  152. package/dist/tests/useTranslation.unit.test.js.map +0 -1
  153. package/dist/useTranslation.d.ts +0 -7
  154. package/dist/useTranslation.d.ts.map +0 -1
  155. package/dist/useTranslation.js +0 -11
  156. package/dist/useTranslation.js.map +0 -1
  157. package/src/tests/useTranslation.unit.test.ts +0 -134
  158. package/src/useTranslation.ts +0 -31
@@ -12,291 +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
- }));
99
- vi.doMock("react-i18next", () => ({ initReactI18next: "initReactI18next" }));
80
+ });
100
81
 
101
82
  const imported = await import("../i18nFrontend.js");
102
- return { initializeI18n: imported.default, harness };
83
+ return {
84
+ initializeI18n: imported.default,
85
+ requested,
86
+ cookie: (): string => cookie,
87
+ stored: (): Record<string, string> => store,
88
+ };
103
89
  };
104
90
 
105
- const backendOf = (
106
- options: InitOptions | undefined,
107
- ): { backends?: unknown[]; backendOptions?: { loadPath?: string }[] } =>
108
- (options?.backend ?? {}) as {
109
- backends?: unknown[];
110
- backendOptions?: { loadPath?: string }[];
111
- };
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
+ });
112
96
 
113
97
  afterEach(() => {
98
+ vi.unstubAllGlobals();
114
99
  vi.resetModules();
115
- for (const module of [
116
- "i18next",
117
- "i18next-chained-backend",
118
- "i18next-http-backend",
119
- "i18next-browser-languagedetector",
120
- "i18next-resources-to-backend",
121
- "react-i18next",
122
- ]) {
123
- vi.doUnmock(module);
124
- }
125
100
  });
126
101
 
127
- describe("i18nFrontend, first initialisation", () => {
128
- test("initialises i18next exactly once", async () => {
129
- const { initializeI18n, harness } = await load();
130
- initializeI18n();
131
- 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");
132
114
  });
133
115
 
134
- test("registers the chained backend, the detector and the react binding", async () => {
135
- const { initializeI18n, harness } = await load();
136
- initializeI18n();
137
- expect(harness.plugins).toHaveLength(3);
138
- expect(harness.plugins[0]).toBe("ChainedBackend");
139
- expect(harness.plugins[2]).toBe("initReactI18next");
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");
140
125
  });
141
126
 
142
- test("carries the shared options through to init", async () => {
143
- const { initializeI18n, harness } = await load();
144
- initializeI18n();
145
- const options = harness.initOptions[0];
146
- expect(options?.fallbackLng).toBe("en");
147
- expect(options?.nonExplicitSupportedLngs).toBe(false);
148
- 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");
149
135
  });
150
136
 
151
- test("returns the i18next instance", async () => {
152
- const { initializeI18n, harness } = await load();
153
- 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");
154
172
  });
155
173
  });
156
174
 
157
- describe("i18nFrontend language selection", () => {
158
- // The whole point of threading lng through init: with it set, i18next never
159
- // consults the browser, so no child component can render against a
160
- // detected language before an effect corrects it.
161
- test("pins lng when the site owner supplied one", async () => {
162
- const { initializeI18n, harness } = await load();
163
- initializeI18n(undefined, "fr");
164
- 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);
165
186
  });
166
187
 
167
- test("omits lng entirely when none was supplied, leaving detection on", async () => {
168
- const { initializeI18n, harness } = await load();
169
- initializeI18n();
170
- 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);
171
199
  });
172
200
 
173
- test("treats the empty string as no language rather than pinning it", async () => {
174
- // An empty `lng` would make i18next resolve nothing at all; falling back
175
- // to detection is the only sane reading of a blank config value.
176
- const { initializeI18n, harness } = await load();
177
- initializeI18n(undefined, "");
178
- 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);
179
207
  });
208
+ });
180
209
 
181
- test("configures the detector to prefer a stored choice over the browser", async () => {
182
- const { initializeI18n, harness } = await load();
183
- initializeI18n();
184
- expect(harness.detectorOptions[0]).toMatchObject({
185
- order: ["cookie", "localStorage", "navigator"],
186
- 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" } } },
187
214
  });
215
+
216
+ expect((await initialised(harness)).t("WIDGET.I_AM_HUMAN")).toBe(
217
+ "I am human",
218
+ );
188
219
  });
189
220
 
190
- test("persists the detected language to localStorage and a cookie", async () => {
191
- const { initializeI18n, harness } = await load();
192
- initializeI18n();
193
- expect(harness.detectorOptions[0]).toMatchObject({
194
- caches: ["localStorage", "cookie"],
221
+ test("substitutes interpolation values", async () => {
222
+ const harness = await load({
223
+ catalogues: { en: { POSITION: "{{x}} across, {{y}} down" } },
195
224
  });
225
+
226
+ expect((await initialised(harness)).t("POSITION", { x: 10, y: 20 })).toBe(
227
+ "10 across, 20 down",
228
+ );
196
229
  });
197
- });
198
230
 
199
- describe("i18nFrontend resource loading", () => {
200
- test("chains an in-memory fallback behind the http backend", async () => {
201
- // If the http load fails — a CDN blip, an offline widget — the bundled
202
- // resources still answer, so the widget renders text rather than keys.
203
- const { initializeI18n, harness } = await load();
204
- initializeI18n();
205
- const backend = backendOf(harness.initOptions[0]);
206
- 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");
207
239
  });
208
240
 
209
- test("gives the http backend a templated load path", async () => {
210
- const { initializeI18n, harness } = await load();
211
- initializeI18n();
212
- const backend = backendOf(harness.initOptions[0]);
213
- const loadPath = backend.backendOptions?.[0]?.loadPath;
214
- 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");
215
249
  });
216
250
 
217
- test("derives the load path from the module URL, not the page URL", async () => {
218
- // The widget is embedded on arbitrary sites, so a page-relative path
219
- // would resolve against the host site and 404.
220
- const { initializeI18n, harness } = await load();
221
- initializeI18n();
222
- const loadPath = backendOf(harness.initOptions[0]).backendOptions?.[0]
223
- ?.loadPath;
224
- // Absolute, with a scheme — file:// here, https:// once bundled and
225
- // served. What matters is that it is not relative.
226
- expect(loadPath).toMatch(/^[a-z]+:\/\//);
227
- // The module's own filename must have been stripped before the locales
228
- // segment is appended, or the path would nest under the script itself.
229
- 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");
230
260
  });
231
261
 
232
- test("hands resourcesToBackend a loader that resolves a language and namespace", async () => {
233
- const { initializeI18n, harness } = await load();
234
- initializeI18n();
235
- const loader = harness.resourceLoaders[0];
236
- expect(loader).toBeTypeOf("function");
237
- // Invoked for its import, whose success depends on the bundler rather
238
- // than on this module; only the call shape is this module's contract.
239
- await expect(
240
- Promise.resolve(loader?.("en", "translation")).catch(() => undefined),
241
- ).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
+ );
242
278
  });
243
279
  });
244
280
 
245
- describe("i18nFrontend callback", () => {
246
- test("defers the callback until resources have loaded", async () => {
247
- const callback = vi.fn();
248
- 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 });
249
286
 
250
- initializeI18n(callback);
251
- expect(callback).not.toHaveBeenCalled();
287
+ const i18n = await initialised(harness);
252
288
 
253
- harness.emit("loaded");
254
- expect(callback).toHaveBeenCalledWith(harness.fake);
289
+ expect(i18n.isInitialized).toBe(true);
290
+ expect(i18n.t("WIDGET.CHECKING", { defaultValue: "Checking" })).toBe(
291
+ "Checking",
292
+ );
255
293
  });
256
294
 
257
- test("fires the callback once, however often `loaded` is emitted", async () => {
258
- // i18next emits `loaded` per namespace and again after changeLanguage,
259
- // so an unguarded listener would repeat whatever side effect the caller
260
- // attached to it.
261
- const callback = vi.fn();
262
- 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 });
263
297
 
264
- initializeI18n(callback);
265
- harness.emit("loaded");
266
- harness.emit("loaded");
267
- expect(callback).toHaveBeenCalledTimes(1);
298
+ expect((await initialised(harness)).hasLoadedNamespace("translation")).toBe(
299
+ true,
300
+ );
268
301
  });
302
+ });
269
303
 
270
- test("tolerates being called with no callback", async () => {
271
- const { initializeI18n, harness } = await load();
272
- initializeI18n();
273
- 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");
274
315
  });
275
316
 
276
- // The `loaded` listener is only registered on the init path, so a second
277
- // widget mounting after the first would otherwise wait on an event that has
278
- // already fired and will not fire again.
279
- test("fires the callback immediately when i18next is already initialised", async () => {
280
- const callback = vi.fn();
281
- 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");
282
324
 
283
- 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);
284
372
 
285
- expect(harness.initOptions).toHaveLength(0);
286
373
  expect(callback).toHaveBeenCalledTimes(1);
287
- expect(callback).toHaveBeenCalledWith(harness.fake);
288
- 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 });
289
382
  });
290
383
 
291
- test("registers no plugins when already initialised", async () => {
292
- const { initializeI18n, harness } = await load(true);
293
- initializeI18n();
294
- expect(harness.plugins).toHaveLength(0);
384
+ test("reports itself uninitialised", () => {
385
+ expect(harness.initializeI18n().isInitialized).toBe(false);
295
386
  });
296
387
 
297
- test("ignores lng when already initialised — the caller reconciles instead", async () => {
298
- const { initializeI18n, harness } = await load(true);
299
- initializeI18n(undefined, "fr");
300
- expect(harness.initOptions).toHaveLength(0);
388
+ test("reports the namespace as not loaded", () => {
389
+ expect(harness.initializeI18n().hasLoadedNamespace("translation")).toBe(
390
+ false,
391
+ );
301
392
  });
302
393
  });