@prosopo/locale 3.6.1 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$cjs.log +8 -9
- package/.turbo/turbo-build$colon$tsc.log +6 -9
- package/.turbo/turbo-build.log +12 -16
- package/CHANGELOG.md +40 -0
- package/LICENSE +201 -0
- package/dist/cjs/direction.cjs +19 -0
- package/dist/cjs/errorMessage.cjs +15 -0
- package/dist/cjs/index.cjs +5 -0
- package/dist/cjs/locales/en/translation.cjs +23 -5
- package/dist/cjs/translationKey.cjs +4 -0
- package/dist/direction.d.ts +3 -0
- package/dist/direction.d.ts.map +1 -0
- package/dist/direction.js +19 -0
- package/dist/direction.js.map +1 -0
- package/dist/errorMessage.d.ts +7 -0
- package/dist/errorMessage.d.ts.map +1 -0
- package/dist/errorMessage.js +15 -0
- package/dist/errorMessage.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/locales/ar/translation.json +23 -5
- package/dist/locales/az/translation.json +23 -5
- package/dist/locales/cs/translation.json +23 -5
- package/dist/locales/de/translation.json +23 -5
- package/dist/locales/el/translation.json +23 -5
- package/dist/locales/en/translation.js +23 -5
- package/dist/locales/en/translation.json +23 -5
- package/dist/locales/es/translation.json +23 -5
- package/dist/locales/fi/translation.json +23 -5
- package/dist/locales/fr/translation.json +23 -5
- package/dist/locales/hi/translation.json +23 -5
- package/dist/locales/hu/translation.json +23 -5
- package/dist/locales/id/translation.json +23 -5
- package/dist/locales/it/translation.json +23 -5
- package/dist/locales/ja/translation.json +23 -5
- package/dist/locales/jv/translation.json +23 -5
- package/dist/locales/ko/translation.json +23 -5
- package/dist/locales/ml/translation.json +23 -5
- package/dist/locales/ms/translation.json +23 -5
- package/dist/locales/nl/translation.json +23 -5
- package/dist/locales/no/translation.json +23 -5
- package/dist/locales/pl/translation.json +23 -5
- package/dist/locales/pt/translation.json +23 -5
- package/dist/locales/pt-BR/translation.json +23 -5
- package/dist/locales/ro/translation.json +23 -5
- package/dist/locales/ru/translation.json +23 -5
- package/dist/locales/sr/translation.json +23 -5
- package/dist/locales/sv/translation.json +23 -5
- package/dist/locales/th/translation.json +23 -5
- package/dist/locales/tr/translation.json +23 -5
- package/dist/locales/uk/translation.json +23 -5
- package/dist/locales/vi/translation.json +23 -5
- package/dist/locales/zh-CN/translation.json +23 -5
- package/dist/tests/direction.unit.test.d.ts +2 -0
- package/dist/tests/direction.unit.test.d.ts.map +1 -0
- package/dist/tests/direction.unit.test.js +31 -0
- package/dist/tests/direction.unit.test.js.map +1 -0
- package/dist/tests/errorMessage.unit.test.d.ts +2 -0
- package/dist/tests/errorMessage.unit.test.d.ts.map +1 -0
- package/dist/tests/errorMessage.unit.test.js +49 -0
- package/dist/tests/errorMessage.unit.test.js.map +1 -0
- package/dist/tests/i18nFrontend.unit.test.js +22 -11
- package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
- package/dist/tests/locale.test-d.js +18 -3
- package/dist/tests/locale.test-d.js.map +1 -1
- package/dist/tests/thrownErrorKeys.unit.test.d.ts +2 -0
- package/dist/tests/thrownErrorKeys.unit.test.d.ts.map +1 -0
- package/dist/tests/thrownErrorKeys.unit.test.js +59 -0
- package/dist/tests/thrownErrorKeys.unit.test.js.map +1 -0
- package/dist/tests/translationKey.unit.test.js +13 -1
- package/dist/tests/translationKey.unit.test.js.map +1 -1
- package/dist/translationKey.d.ts +7 -1
- package/dist/translationKey.d.ts.map +1 -1
- package/dist/translationKey.js +4 -1
- package/dist/translationKey.js.map +1 -1
- package/dist/translator.d.ts +2 -1
- package/dist/translator.d.ts.map +1 -1
- package/dist/translator.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +65 -66
- package/src/i18SharedOptions.ts +0 -29
- package/src/i18nBackend.ts +0 -69
- package/src/i18nFrontend.ts +0 -267
- package/src/i18nMiddleware.ts +0 -35
- package/src/index.ts +0 -29
- package/src/loadI18next.ts +0 -136
- package/src/locales/ar/translation.json +0 -476
- package/src/locales/az/translation.json +0 -476
- package/src/locales/cs/translation.json +0 -476
- package/src/locales/de/translation.json +0 -476
- package/src/locales/el/translation.json +0 -476
- package/src/locales/en/translation.json +0 -476
- package/src/locales/es/translation.json +0 -476
- package/src/locales/fi/translation.json +0 -476
- package/src/locales/fr/translation.json +0 -476
- package/src/locales/hi/translation.json +0 -476
- package/src/locales/hu/translation.json +0 -476
- package/src/locales/id/translation.json +0 -476
- package/src/locales/it/translation.json +0 -476
- package/src/locales/ja/translation.json +0 -476
- package/src/locales/jv/translation.json +0 -476
- package/src/locales/ko/translation.json +0 -476
- package/src/locales/ml/translation.json +0 -476
- package/src/locales/ms/translation.json +0 -476
- package/src/locales/nl/translation.json +0 -476
- package/src/locales/no/translation.json +0 -476
- package/src/locales/pl/translation.json +0 -476
- package/src/locales/pt/translation.json +0 -476
- package/src/locales/pt-BR/translation.json +0 -476
- package/src/locales/ro/translation.json +0 -476
- package/src/locales/ru/translation.json +0 -476
- package/src/locales/sr/translation.json +0 -476
- package/src/locales/sv/translation.json +0 -476
- package/src/locales/th/translation.json +0 -476
- package/src/locales/tr/translation.json +0 -476
- package/src/locales/uk/translation.json +0 -476
- package/src/locales/vi/translation.json +0 -476
- package/src/locales/zh-CN/translation.json +0 -476
- package/src/process.ts +0 -29
- package/src/tests/i18SharedOptions.unit.test.ts +0 -139
- package/src/tests/i18nBackend.unit.test.ts +0 -241
- package/src/tests/i18nFrontend.unit.test.ts +0 -393
- package/src/tests/i18nMiddleware.unit.test.ts +0 -159
- package/src/tests/loadI18next.unit.test.ts +0 -443
- package/src/tests/locale.test-d.ts +0 -160
- package/src/tests/localePlaceholders.unit.test.ts +0 -81
- package/src/tests/locales.unit.test.ts +0 -79
- package/src/tests/process.unit.test.ts +0 -33
- package/src/tests/translationKey.unit.test.ts +0 -140
- package/src/tests/translations.unit.test.ts +0 -97
- package/src/tests/translator.unit.test.ts +0 -213
- package/src/tests/util.unit.test.ts +0 -102
- package/src/translationKey.ts +0 -64
- package/src/translations.ts +0 -55
- package/src/translator.ts +0 -68
- package/src/types.ts +0 -42
- package/src/util.ts +0 -25
- package/tsconfig.cjs.json +0 -21
- package/tsconfig.json +0 -22
- package/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.types.json +0 -9
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import type { 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
|
-
});
|
|
@@ -1,393 +0,0 @@
|
|
|
1
|
-
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
-
//
|
|
3
|
-
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
// you may not use this file except in compliance with the License.
|
|
5
|
-
// You may obtain a copy of the License at
|
|
6
|
-
//
|
|
7
|
-
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
//
|
|
9
|
-
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
// See the License for the specific language governing permissions and
|
|
13
|
-
// limitations under the License.
|
|
14
|
-
|
|
15
|
-
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
16
|
-
import type { Ti18n } from "../types.js";
|
|
17
|
-
|
|
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.
|
|
21
|
-
|
|
22
|
-
type Catalogues = Record<string, Record<string, unknown>>;
|
|
23
|
-
|
|
24
|
-
interface Harness {
|
|
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>;
|
|
30
|
-
}
|
|
31
|
-
|
|
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> => {
|
|
40
|
-
vi.resetModules();
|
|
41
|
-
|
|
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);
|
|
54
|
-
}
|
|
55
|
-
if (options.fetchFails) {
|
|
56
|
-
throw new Error("network down");
|
|
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;
|
|
76
|
-
},
|
|
77
|
-
set cookie(value: string) {
|
|
78
|
-
cookie = value;
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const imported = await import("../i18nFrontend.js");
|
|
83
|
-
return {
|
|
84
|
-
initializeI18n: imported.default,
|
|
85
|
-
requested,
|
|
86
|
-
cookie: (): string => cookie,
|
|
87
|
-
stored: (): Record<string, string> => store,
|
|
88
|
-
};
|
|
89
|
-
};
|
|
90
|
-
|
|
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
|
-
});
|
|
96
|
-
|
|
97
|
-
afterEach(() => {
|
|
98
|
-
vi.unstubAllGlobals();
|
|
99
|
-
vi.resetModules();
|
|
100
|
-
});
|
|
101
|
-
|
|
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");
|
|
114
|
-
});
|
|
115
|
-
|
|
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");
|
|
125
|
-
});
|
|
126
|
-
|
|
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");
|
|
135
|
-
});
|
|
136
|
-
|
|
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");
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
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);
|
|
186
|
-
});
|
|
187
|
-
|
|
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);
|
|
199
|
-
});
|
|
200
|
-
|
|
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);
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
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" } } },
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
expect((await initialised(harness)).t("WIDGET.I_AM_HUMAN")).toBe(
|
|
217
|
-
"I am human",
|
|
218
|
-
);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test("substitutes interpolation values", async () => {
|
|
222
|
-
const harness = await load({
|
|
223
|
-
catalogues: { en: { POSITION: "{{x}} across, {{y}} down" } },
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
expect((await initialised(harness)).t("POSITION", { x: 10, y: 20 })).toBe(
|
|
227
|
-
"10 across, 20 down",
|
|
228
|
-
);
|
|
229
|
-
});
|
|
230
|
-
|
|
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");
|
|
239
|
-
});
|
|
240
|
-
|
|
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");
|
|
249
|
-
});
|
|
250
|
-
|
|
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");
|
|
260
|
-
});
|
|
261
|
-
|
|
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
|
-
);
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
|
|
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 });
|
|
286
|
-
|
|
287
|
-
const i18n = await initialised(harness);
|
|
288
|
-
|
|
289
|
-
expect(i18n.isInitialized).toBe(true);
|
|
290
|
-
expect(i18n.t("WIDGET.CHECKING", { defaultValue: "Checking" })).toBe(
|
|
291
|
-
"Checking",
|
|
292
|
-
);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test("reports the namespace as loaded so callers stop waiting", async () => {
|
|
296
|
-
const harness = await load({ catalogues: {}, fetchFails: true });
|
|
297
|
-
|
|
298
|
-
expect((await initialised(harness)).hasLoadedNamespace("translation")).toBe(
|
|
299
|
-
true,
|
|
300
|
-
);
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
|
|
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");
|
|
315
|
-
});
|
|
316
|
-
|
|
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");
|
|
324
|
-
|
|
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);
|
|
372
|
-
|
|
373
|
-
expect(callback).toHaveBeenCalledTimes(1);
|
|
374
|
-
});
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
describe("before the catalogue arrives", () => {
|
|
378
|
-
let harness: Harness;
|
|
379
|
-
|
|
380
|
-
beforeEach(async () => {
|
|
381
|
-
harness = await load({ never: true });
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("reports itself uninitialised", () => {
|
|
385
|
-
expect(harness.initializeI18n().isInitialized).toBe(false);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
test("reports the namespace as not loaded", () => {
|
|
389
|
-
expect(harness.initializeI18n().hasLoadedNamespace("translation")).toBe(
|
|
390
|
-
false,
|
|
391
|
-
);
|
|
392
|
-
});
|
|
393
|
-
});
|