@prosopo/locale 3.2.8 → 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.
- package/.turbo/turbo-build$colon$cjs.log +10 -9
- package/.turbo/turbo-build$colon$tsc.log +5 -5
- package/.turbo/turbo-build.log +11 -10
- package/CHANGELOG.md +36 -0
- package/dist/cjs/i18SharedOptions.cjs +1 -1
- package/dist/cjs/i18nBackend.cjs +4 -1
- package/dist/cjs/i18nFrontend.cjs +4 -1
- package/dist/cjs/loadI18next.cjs +41 -14
- package/dist/cjs/locales/en/translation.cjs +1 -0
- package/dist/cjs/process.cjs +17 -0
- package/dist/cjs/translationKey.cjs +2 -0
- package/dist/i18SharedOptions.js +2 -1
- package/dist/i18SharedOptions.js.map +1 -1
- package/dist/i18nBackend.d.ts.map +1 -1
- package/dist/i18nBackend.js +4 -1
- package/dist/i18nBackend.js.map +1 -1
- package/dist/i18nFrontend.d.ts.map +1 -1
- package/dist/i18nFrontend.js +4 -1
- package/dist/i18nFrontend.js.map +1 -1
- package/dist/loadI18next.d.ts +1 -0
- package/dist/loadI18next.d.ts.map +1 -1
- package/dist/loadI18next.js +41 -15
- package/dist/loadI18next.js.map +1 -1
- package/dist/locales/ar/translation.json +1 -0
- package/dist/locales/az/translation.json +1 -0
- package/dist/locales/cs/translation.json +1 -0
- package/dist/locales/de/translation.json +1 -0
- package/dist/locales/el/translation.json +1 -0
- package/dist/locales/en/translation.js +1 -0
- package/dist/locales/en/translation.json +1 -0
- package/dist/locales/es/translation.json +1 -0
- package/dist/locales/fi/translation.json +1 -0
- package/dist/locales/fr/translation.json +1 -0
- package/dist/locales/hi/translation.json +1 -0
- package/dist/locales/hu/translation.json +1 -0
- package/dist/locales/id/translation.json +1 -0
- package/dist/locales/it/translation.json +1 -0
- package/dist/locales/ja/translation.json +1 -0
- package/dist/locales/jv/translation.json +1 -0
- package/dist/locales/ko/translation.json +1 -0
- package/dist/locales/ml/translation.json +1 -0
- package/dist/locales/ms/translation.json +1 -0
- package/dist/locales/nl/translation.json +1 -0
- package/dist/locales/no/translation.json +1 -0
- package/dist/locales/pl/translation.json +1 -0
- package/dist/locales/pt/translation.json +1 -0
- package/dist/locales/pt-BR/translation.json +1 -0
- package/dist/locales/ro/translation.json +1 -0
- package/dist/locales/ru/translation.json +1 -0
- package/dist/locales/sr/translation.json +1 -0
- package/dist/locales/sv/translation.json +1 -0
- package/dist/locales/th/translation.json +1 -0
- package/dist/locales/tr/translation.json +1 -0
- package/dist/locales/uk/translation.json +1 -0
- package/dist/locales/vi/translation.json +1 -0
- package/dist/locales/zh-CN/translation.json +1 -0
- package/dist/process.d.ts +2 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +17 -0
- package/dist/process.js.map +1 -0
- package/dist/tests/i18SharedOptions.unit.test.d.ts +2 -0
- package/dist/tests/i18SharedOptions.unit.test.d.ts.map +1 -0
- package/dist/tests/i18SharedOptions.unit.test.js +91 -0
- package/dist/tests/i18SharedOptions.unit.test.js.map +1 -0
- package/dist/tests/i18nBackend.unit.test.d.ts +2 -0
- package/dist/tests/i18nBackend.unit.test.d.ts.map +1 -0
- package/dist/tests/i18nBackend.unit.test.js +153 -0
- package/dist/tests/i18nBackend.unit.test.js.map +1 -0
- package/dist/tests/i18nFrontend.unit.test.d.ts +2 -0
- package/dist/tests/i18nFrontend.unit.test.d.ts.map +1 -0
- package/dist/tests/i18nFrontend.unit.test.js +199 -0
- package/dist/tests/i18nFrontend.unit.test.js.map +1 -0
- package/dist/tests/i18nMiddleware.unit.test.d.ts +2 -0
- package/dist/tests/i18nMiddleware.unit.test.d.ts.map +1 -0
- package/dist/tests/i18nMiddleware.unit.test.js +80 -0
- package/dist/tests/i18nMiddleware.unit.test.js.map +1 -0
- package/dist/tests/loadI18next.unit.test.d.ts +2 -0
- package/dist/tests/loadI18next.unit.test.d.ts.map +1 -0
- package/dist/tests/loadI18next.unit.test.js +270 -0
- package/dist/tests/loadI18next.unit.test.js.map +1 -0
- package/dist/tests/locale.test-d.d.ts +2 -0
- package/dist/tests/locale.test-d.d.ts.map +1 -0
- package/dist/tests/locale.test-d.js +88 -0
- package/dist/tests/locale.test-d.js.map +1 -0
- package/dist/tests/process.unit.test.d.ts +2 -0
- package/dist/tests/process.unit.test.d.ts.map +1 -0
- package/dist/tests/process.unit.test.js +14 -0
- package/dist/tests/process.unit.test.js.map +1 -0
- package/dist/tests/translationKey.unit.test.d.ts +2 -0
- package/dist/tests/translationKey.unit.test.d.ts.map +1 -0
- package/dist/tests/translationKey.unit.test.js +108 -0
- package/dist/tests/translationKey.unit.test.js.map +1 -0
- package/dist/tests/translations.unit.test.d.ts +2 -0
- package/dist/tests/translations.unit.test.d.ts.map +1 -0
- package/dist/tests/translations.unit.test.js +74 -0
- package/dist/tests/translations.unit.test.js.map +1 -0
- package/dist/tests/useTranslation.unit.test.d.ts +2 -0
- package/dist/tests/useTranslation.unit.test.d.ts.map +1 -0
- package/dist/tests/useTranslation.unit.test.js +81 -0
- package/dist/tests/useTranslation.unit.test.js.map +1 -0
- package/dist/tests/util.unit.test.d.ts +2 -0
- package/dist/tests/util.unit.test.d.ts.map +1 -0
- package/dist/tests/util.unit.test.js +66 -0
- package/dist/tests/util.unit.test.js.map +1 -0
- package/dist/translationKey.d.ts +4 -0
- package/dist/translationKey.d.ts.map +1 -1
- package/dist/translationKey.js +2 -1
- package/dist/translationKey.js.map +1 -1
- package/package.json +2 -2
- package/src/i18SharedOptions.ts +4 -4
- package/src/i18nBackend.ts +16 -0
- package/src/i18nFrontend.ts +16 -0
- package/src/loadI18next.ts +83 -15
- package/src/locales/ar/translation.json +1 -0
- package/src/locales/az/translation.json +1 -0
- package/src/locales/cs/translation.json +1 -0
- package/src/locales/de/translation.json +1 -0
- package/src/locales/el/translation.json +1 -0
- package/src/locales/en/translation.json +1 -0
- package/src/locales/es/translation.json +1 -0
- package/src/locales/fi/translation.json +1 -0
- package/src/locales/fr/translation.json +1 -0
- package/src/locales/hi/translation.json +1 -0
- package/src/locales/hu/translation.json +1 -0
- package/src/locales/id/translation.json +1 -0
- package/src/locales/it/translation.json +1 -0
- package/src/locales/ja/translation.json +1 -0
- package/src/locales/jv/translation.json +1 -0
- package/src/locales/ko/translation.json +1 -0
- package/src/locales/ml/translation.json +1 -0
- package/src/locales/ms/translation.json +1 -0
- package/src/locales/nl/translation.json +1 -0
- package/src/locales/no/translation.json +1 -0
- package/src/locales/pl/translation.json +1 -0
- package/src/locales/pt/translation.json +1 -0
- package/src/locales/pt-BR/translation.json +1 -0
- package/src/locales/ro/translation.json +1 -0
- package/src/locales/ru/translation.json +1 -0
- package/src/locales/sr/translation.json +1 -0
- package/src/locales/sv/translation.json +1 -0
- package/src/locales/th/translation.json +1 -0
- package/src/locales/tr/translation.json +1 -0
- package/src/locales/uk/translation.json +1 -0
- package/src/locales/vi/translation.json +1 -0
- package/src/locales/zh-CN/translation.json +1 -0
- package/src/process.ts +29 -0
- package/src/tests/i18SharedOptions.unit.test.ts +139 -0
- package/src/tests/i18nBackend.unit.test.ts +241 -0
- package/src/tests/i18nFrontend.unit.test.ts +302 -0
- package/src/tests/i18nMiddleware.unit.test.ts +140 -0
- package/src/tests/loadI18next.unit.test.ts +443 -0
- package/src/tests/locale.test-d.ts +156 -0
- package/src/tests/process.unit.test.ts +33 -0
- package/src/tests/translationKey.unit.test.ts +166 -0
- package/src/tests/translations.unit.test.ts +110 -0
- package/src/tests/useTranslation.unit.test.ts +134 -0
- package/src/tests/util.unit.test.ts +102 -0
- package/src/translationKey.ts +18 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
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 { InitOptions, i18n } from "i18next";
|
|
16
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
// i18next is a mutable singleton and the module under test configures it as a
|
|
19
|
+
// side effect of the first call. Both are mocked so each test starts from a
|
|
20
|
+
// known `isInitialized` state without a real filesystem backend.
|
|
21
|
+
|
|
22
|
+
type Listener = (...args: unknown[]) => void;
|
|
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
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Harness {
|
|
32
|
+
fake: FakeI18n;
|
|
33
|
+
plugins: unknown[];
|
|
34
|
+
initOptions: InitOptions[];
|
|
35
|
+
listeners: Map<string, Listener[]>;
|
|
36
|
+
emit: (event: string) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const makeHarness = (isInitialized: boolean): Harness => {
|
|
40
|
+
const plugins: unknown[] = [];
|
|
41
|
+
const initOptions: InitOptions[] = [];
|
|
42
|
+
const listeners = new Map<string, Listener[]>();
|
|
43
|
+
|
|
44
|
+
const fake: FakeI18n = {
|
|
45
|
+
isInitialized,
|
|
46
|
+
use: (plugin: unknown): FakeI18n => {
|
|
47
|
+
plugins.push(plugin);
|
|
48
|
+
return fake;
|
|
49
|
+
},
|
|
50
|
+
init: (options: InitOptions): FakeI18n => {
|
|
51
|
+
initOptions.push(options);
|
|
52
|
+
return fake;
|
|
53
|
+
},
|
|
54
|
+
on: (event: string, listener: Listener): void => {
|
|
55
|
+
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
fake,
|
|
61
|
+
plugins,
|
|
62
|
+
initOptions,
|
|
63
|
+
listeners,
|
|
64
|
+
emit: (event: string): void => {
|
|
65
|
+
for (const listener of listeners.get(event) ?? []) {
|
|
66
|
+
listener();
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load the module under a fresh registry with i18next, the filesystem backend
|
|
74
|
+
* and the side detection all mocked.
|
|
75
|
+
*/
|
|
76
|
+
const load = async (options: {
|
|
77
|
+
isInitialized?: boolean;
|
|
78
|
+
serverSide?: boolean;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
initializeI18n: (callback?: (value: i18n) => void) => i18n;
|
|
81
|
+
harness: Harness;
|
|
82
|
+
}> => {
|
|
83
|
+
vi.resetModules();
|
|
84
|
+
const harness = makeHarness(options.isInitialized ?? false);
|
|
85
|
+
|
|
86
|
+
vi.doMock("i18next", () => ({ default: harness.fake }));
|
|
87
|
+
vi.doMock("i18next-fs-backend/cjs", () => ({ default: "FSBackend" }));
|
|
88
|
+
vi.doMock("i18next-http-middleware", () => ({
|
|
89
|
+
LanguageDetector: class {
|
|
90
|
+
options: unknown;
|
|
91
|
+
constructor(_services: unknown, opts: unknown) {
|
|
92
|
+
this.options = opts;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
vi.doMock("../util.js", () => ({
|
|
97
|
+
isServerSide: (): boolean => options.serverSide ?? true,
|
|
98
|
+
isClientSide: (): boolean => !(options.serverSide ?? true),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const imported = await import("../i18nBackend.js");
|
|
102
|
+
return { initializeI18n: imported.default, harness };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.resetModules();
|
|
107
|
+
vi.doUnmock("i18next");
|
|
108
|
+
vi.doUnmock("i18next-fs-backend/cjs");
|
|
109
|
+
vi.doUnmock("i18next-http-middleware");
|
|
110
|
+
vi.doUnmock("../util.js");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("i18nBackend on a server, not yet initialised", () => {
|
|
114
|
+
test("initialises i18next exactly once", async () => {
|
|
115
|
+
const { initializeI18n, harness } = await load({});
|
|
116
|
+
initializeI18n();
|
|
117
|
+
expect(harness.initOptions).toHaveLength(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("registers the filesystem backend and the header language detector", async () => {
|
|
121
|
+
// The detector is what makes Accept-Language work; losing it would
|
|
122
|
+
// silently serve every request in the fallback language.
|
|
123
|
+
const { initializeI18n, harness } = await load({});
|
|
124
|
+
initializeI18n();
|
|
125
|
+
expect(harness.plugins).toHaveLength(2);
|
|
126
|
+
expect(harness.plugins[0]).toBe("FSBackend");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("carries the shared options through to init", async () => {
|
|
130
|
+
const { initializeI18n, harness } = await load({});
|
|
131
|
+
initializeI18n();
|
|
132
|
+
const options = harness.initOptions[0];
|
|
133
|
+
expect(options?.fallbackLng).toBe("en");
|
|
134
|
+
expect(options?.supportedLngs).toContain("en");
|
|
135
|
+
expect(options?.nonExplicitSupportedLngs).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("declares the translation namespace", async () => {
|
|
139
|
+
const { initializeI18n, harness } = await load({});
|
|
140
|
+
initializeI18n();
|
|
141
|
+
expect(harness.initOptions[0]?.ns).toEqual(["translation"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("points the backend at a filesystem path, not a file:// URL", async () => {
|
|
145
|
+
// i18next-fs-backend reads with node:fs, which cannot open a file://
|
|
146
|
+
// URL — the module strips the scheme for exactly this reason.
|
|
147
|
+
const { initializeI18n, harness } = await load({});
|
|
148
|
+
initializeI18n();
|
|
149
|
+
const backend = harness.initOptions[0]?.backend as
|
|
150
|
+
| { loadPath?: string }
|
|
151
|
+
| undefined;
|
|
152
|
+
expect(backend?.loadPath).toBeDefined();
|
|
153
|
+
expect(backend?.loadPath).not.toContain("file://");
|
|
154
|
+
expect(backend?.loadPath).toContain("/locales/{{lng}}/{{ns}}.json");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("defers the callback until resources have loaded", async () => {
|
|
158
|
+
const callback = vi.fn();
|
|
159
|
+
const { initializeI18n, harness } = await load({});
|
|
160
|
+
|
|
161
|
+
initializeI18n(callback);
|
|
162
|
+
expect(callback).not.toHaveBeenCalled();
|
|
163
|
+
|
|
164
|
+
harness.emit("loaded");
|
|
165
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("fires the callback once, however often `loaded` is emitted", async () => {
|
|
169
|
+
// i18next emits `loaded` per namespace and again after changeLanguage,
|
|
170
|
+
// so an unguarded listener would repeat whatever side effect the caller
|
|
171
|
+
// attached to it.
|
|
172
|
+
const callback = vi.fn();
|
|
173
|
+
const { initializeI18n, harness } = await load({});
|
|
174
|
+
|
|
175
|
+
initializeI18n(callback);
|
|
176
|
+
harness.emit("loaded");
|
|
177
|
+
harness.emit("loaded");
|
|
178
|
+
harness.emit("loaded");
|
|
179
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("hands the i18next instance to the callback", async () => {
|
|
183
|
+
const callback = vi.fn();
|
|
184
|
+
const { initializeI18n, harness } = await load({});
|
|
185
|
+
initializeI18n(callback);
|
|
186
|
+
harness.emit("loaded");
|
|
187
|
+
expect(callback).toHaveBeenCalledWith(harness.fake);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("returns the i18next instance", async () => {
|
|
191
|
+
const { initializeI18n, harness } = await load({});
|
|
192
|
+
expect(initializeI18n()).toBe(harness.fake);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("tolerates being called with no callback at all", async () => {
|
|
196
|
+
const { initializeI18n, harness } = await load({});
|
|
197
|
+
initializeI18n();
|
|
198
|
+
expect(() => harness.emit("loaded")).not.toThrow();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Both of these skip initialisation. The `loaded` listener is only registered
|
|
203
|
+
// on the init path, so without an explicit callback here the caller
|
|
204
|
+
// (loadI18next) would wait forever for an event that is never coming.
|
|
205
|
+
describe("i18nBackend when there is nothing to initialise", () => {
|
|
206
|
+
test("fires the callback immediately when i18next is already up", async () => {
|
|
207
|
+
const callback = vi.fn();
|
|
208
|
+
const { initializeI18n, harness } = await load({ isInitialized: true });
|
|
209
|
+
|
|
210
|
+
const returned = initializeI18n(callback);
|
|
211
|
+
|
|
212
|
+
expect(harness.initOptions).toHaveLength(0);
|
|
213
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(callback).toHaveBeenCalledWith(harness.fake);
|
|
215
|
+
expect(returned).toBe(harness.fake);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("fires the callback immediately when running client side", async () => {
|
|
219
|
+
// The filesystem backend cannot work in a browser, so initialisation is
|
|
220
|
+
// correctly skipped — but the caller still has to be released.
|
|
221
|
+
const callback = vi.fn();
|
|
222
|
+
const { initializeI18n, harness } = await load({ serverSide: false });
|
|
223
|
+
|
|
224
|
+
const returned = initializeI18n(callback);
|
|
225
|
+
|
|
226
|
+
expect(harness.initOptions).toHaveLength(0);
|
|
227
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(returned).toBe(harness.fake);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("registers no plugins when initialisation is skipped", async () => {
|
|
232
|
+
const { initializeI18n, harness } = await load({ isInitialized: true });
|
|
233
|
+
initializeI18n();
|
|
234
|
+
expect(harness.plugins).toHaveLength(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("tolerates no callback when initialisation is skipped", async () => {
|
|
238
|
+
const { initializeI18n } = await load({ isInitialized: true });
|
|
239
|
+
expect(() => initializeI18n()).not.toThrow();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
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 { InitOptions, i18n } from "i18next";
|
|
16
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
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.
|
|
20
|
+
|
|
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
|
+
}
|
|
30
|
+
|
|
31
|
+
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;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const load = async (
|
|
42
|
+
isInitialized = false,
|
|
43
|
+
): Promise<{
|
|
44
|
+
initializeI18n: (callback?: (value: i18n) => void, lng?: string) => i18n;
|
|
45
|
+
harness: Harness;
|
|
46
|
+
}> => {
|
|
47
|
+
vi.resetModules();
|
|
48
|
+
|
|
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();
|
|
79
|
+
}
|
|
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);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
vi.doMock("i18next-resources-to-backend", () => ({
|
|
94
|
+
default: (loader: ResourceLoader): string => {
|
|
95
|
+
resourceLoaders.push(loader);
|
|
96
|
+
return "InMemoryBackend";
|
|
97
|
+
},
|
|
98
|
+
}));
|
|
99
|
+
vi.doMock("react-i18next", () => ({ initReactI18next: "initReactI18next" }));
|
|
100
|
+
|
|
101
|
+
const imported = await import("../i18nFrontend.js");
|
|
102
|
+
return { initializeI18n: imported.default, harness };
|
|
103
|
+
};
|
|
104
|
+
|
|
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
|
+
};
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
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
|
+
});
|
|
126
|
+
|
|
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);
|
|
132
|
+
});
|
|
133
|
+
|
|
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");
|
|
140
|
+
});
|
|
141
|
+
|
|
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");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("returns the i18next instance", async () => {
|
|
152
|
+
const { initializeI18n, harness } = await load();
|
|
153
|
+
expect(initializeI18n()).toBe(harness.fake);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
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");
|
|
165
|
+
});
|
|
166
|
+
|
|
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");
|
|
171
|
+
});
|
|
172
|
+
|
|
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");
|
|
179
|
+
});
|
|
180
|
+
|
|
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",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
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"],
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
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"]);
|
|
207
|
+
});
|
|
208
|
+
|
|
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");
|
|
215
|
+
});
|
|
216
|
+
|
|
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");
|
|
230
|
+
});
|
|
231
|
+
|
|
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();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
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();
|
|
249
|
+
|
|
250
|
+
initializeI18n(callback);
|
|
251
|
+
expect(callback).not.toHaveBeenCalled();
|
|
252
|
+
|
|
253
|
+
harness.emit("loaded");
|
|
254
|
+
expect(callback).toHaveBeenCalledWith(harness.fake);
|
|
255
|
+
});
|
|
256
|
+
|
|
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();
|
|
263
|
+
|
|
264
|
+
initializeI18n(callback);
|
|
265
|
+
harness.emit("loaded");
|
|
266
|
+
harness.emit("loaded");
|
|
267
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("tolerates being called with no callback", async () => {
|
|
271
|
+
const { initializeI18n, harness } = await load();
|
|
272
|
+
initializeI18n();
|
|
273
|
+
expect(() => harness.emit("loaded")).not.toThrow();
|
|
274
|
+
});
|
|
275
|
+
|
|
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);
|
|
282
|
+
|
|
283
|
+
const returned = initializeI18n(callback);
|
|
284
|
+
|
|
285
|
+
expect(harness.initOptions).toHaveLength(0);
|
|
286
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
287
|
+
expect(callback).toHaveBeenCalledWith(harness.fake);
|
|
288
|
+
expect(returned).toBe(harness.fake);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("registers no plugins when already initialised", async () => {
|
|
292
|
+
const { initializeI18n, harness } = await load(true);
|
|
293
|
+
initializeI18n();
|
|
294
|
+
expect(harness.plugins).toHaveLength(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
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);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
}
|
|
29
|
+
|
|
30
|
+
const load = async (options: {
|
|
31
|
+
loadRejects?: boolean;
|
|
32
|
+
}): Promise<{
|
|
33
|
+
i18nMiddleware: (opts: HandleOptions) => Promise<Handler>;
|
|
34
|
+
harness: Harness;
|
|
35
|
+
}> => {
|
|
36
|
+
vi.resetModules();
|
|
37
|
+
|
|
38
|
+
const instance = asI18n({ name: "the singleton" });
|
|
39
|
+
const handleCalls: [i18n, HandleOptions | undefined][] = [];
|
|
40
|
+
const handler = ((): void => {}) as unknown as Handler;
|
|
41
|
+
|
|
42
|
+
vi.doMock("i18next-http-middleware", () => ({
|
|
43
|
+
handle: (i18next: i18n, opts?: HandleOptions): Handler => {
|
|
44
|
+
handleCalls.push([i18next, opts]);
|
|
45
|
+
return handler;
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
vi.doMock("../loadI18next.js", () => ({
|
|
49
|
+
default: async (): Promise<i18n> => {
|
|
50
|
+
if (options.loadRejects) {
|
|
51
|
+
throw new Error("i18n failed to load");
|
|
52
|
+
}
|
|
53
|
+
return instance;
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const imported = await import("../i18nMiddleware.js");
|
|
58
|
+
return {
|
|
59
|
+
i18nMiddleware: imported.default,
|
|
60
|
+
harness: { instance, handleCalls, handler },
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.resetModules();
|
|
66
|
+
vi.doUnmock("i18next-http-middleware");
|
|
67
|
+
vi.doUnmock("../loadI18next.js");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("i18nMiddleware", () => {
|
|
71
|
+
test("builds the handler from the loaded i18next instance", async () => {
|
|
72
|
+
const { i18nMiddleware, harness } = await load({});
|
|
73
|
+
|
|
74
|
+
const handler = await i18nMiddleware({});
|
|
75
|
+
|
|
76
|
+
expect(harness.handleCalls).toHaveLength(1);
|
|
77
|
+
expect(harness.handleCalls[0]?.[0]).toBe(harness.instance);
|
|
78
|
+
expect(handler).toBe(harness.handler);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("forwards the caller's options", async () => {
|
|
82
|
+
const { i18nMiddleware, harness } = await load({});
|
|
83
|
+
const options: HandleOptions = { ignoreRoutes: ["/healthz"] };
|
|
84
|
+
|
|
85
|
+
await i18nMiddleware(options);
|
|
86
|
+
|
|
87
|
+
expect(harness.handleCalls[0]?.[1]).toEqual({ ignoreRoutes: ["/healthz"] });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("copies the options rather than passing the caller's object through", async () => {
|
|
91
|
+
// handle() keeps the object for the lifetime of the process, so sharing
|
|
92
|
+
// it would let a later mutation by the caller silently change routing.
|
|
93
|
+
const { i18nMiddleware, harness } = await load({});
|
|
94
|
+
const options: HandleOptions = { ignoreRoutes: ["/healthz"] };
|
|
95
|
+
|
|
96
|
+
await i18nMiddleware(options);
|
|
97
|
+
|
|
98
|
+
expect(harness.handleCalls[0]?.[1]).not.toBe(options);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("accepts an empty options object", async () => {
|
|
102
|
+
const { i18nMiddleware, harness } = await load({});
|
|
103
|
+
await i18nMiddleware({});
|
|
104
|
+
expect(harness.handleCalls[0]?.[1]).toEqual({});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("always requests the backend instance", async () => {
|
|
108
|
+
// Middleware only ever runs on the server; asking for the frontend
|
|
109
|
+
// instance would pull in the browser detector and http backend.
|
|
110
|
+
const loadSpy = vi.fn(async (): Promise<i18n> => asI18n({}));
|
|
111
|
+
vi.resetModules();
|
|
112
|
+
vi.doMock("i18next-http-middleware", () => ({
|
|
113
|
+
handle: (): Handler => ((): void => {}) as unknown as Handler,
|
|
114
|
+
}));
|
|
115
|
+
vi.doMock("../loadI18next.js", () => ({ default: loadSpy }));
|
|
116
|
+
|
|
117
|
+
const imported = await import("../i18nMiddleware.js");
|
|
118
|
+
await imported.default({});
|
|
119
|
+
|
|
120
|
+
expect(loadSpy).toHaveBeenCalledWith(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("propagates a failure to load i18next instead of returning a broken handler", async () => {
|
|
124
|
+
// The caller mounts whatever this resolves to. Swallowing the error
|
|
125
|
+
// would mount a handler over an uninitialised i18next and fail on the
|
|
126
|
+
// first request instead of at boot.
|
|
127
|
+
const { i18nMiddleware } = await load({ loadRejects: true });
|
|
128
|
+
|
|
129
|
+
await expect(i18nMiddleware({})).rejects.toThrow("i18n failed to load");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("returns a fresh handler per call", async () => {
|
|
133
|
+
const { i18nMiddleware, harness } = await load({});
|
|
134
|
+
|
|
135
|
+
await i18nMiddleware({});
|
|
136
|
+
await i18nMiddleware({ ignoreRoutes: ["/x"] });
|
|
137
|
+
|
|
138
|
+
expect(harness.handleCalls).toHaveLength(2);
|
|
139
|
+
});
|
|
140
|
+
});
|