@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.
- package/.turbo/turbo-build$colon$cjs.log +15 -13
- package/.turbo/turbo-build$colon$tsc.log +8 -5
- package/.turbo/turbo-build.log +17 -15
- package/CHANGELOG.md +74 -0
- package/dist/cjs/i18SharedOptions.cjs +2 -2
- package/dist/cjs/i18nBackend.cjs +1 -1
- package/dist/cjs/i18nFrontend.cjs +167 -53
- package/dist/cjs/i18nMiddleware.cjs +5 -2
- package/dist/cjs/index.cjs +5 -4
- package/dist/cjs/locales/en/translation.cjs +1 -0
- package/dist/cjs/translationKey.cjs +2 -3
- package/dist/cjs/translations.cjs +4 -5
- package/dist/cjs/translator.cjs +29 -0
- package/dist/i18SharedOptions.d.ts +2 -2
- package/dist/i18SharedOptions.js +3 -3
- package/dist/i18SharedOptions.js.map +1 -1
- package/dist/i18nBackend.js +1 -1
- package/dist/i18nFrontend.d.ts +2 -2
- package/dist/i18nFrontend.d.ts.map +1 -1
- package/dist/i18nFrontend.js +167 -47
- package/dist/i18nFrontend.js.map +1 -1
- package/dist/i18nMiddleware.d.ts.map +1 -1
- package/dist/i18nMiddleware.js +3 -1
- package/dist/i18nMiddleware.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/loadI18next.d.ts +2 -3
- package/dist/loadI18next.d.ts.map +1 -1
- 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/tests/i18nFrontend.unit.test.js +218 -173
- package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
- package/dist/tests/i18nMiddleware.unit.test.js +11 -2
- package/dist/tests/i18nMiddleware.unit.test.js.map +1 -1
- package/dist/tests/loadI18next.unit.test.js.map +1 -1
- package/dist/tests/locale.test-d.js +27 -20
- package/dist/tests/locale.test-d.js.map +1 -1
- package/dist/tests/translationKey.unit.test.js +11 -33
- package/dist/tests/translationKey.unit.test.js.map +1 -1
- package/dist/tests/translations.unit.test.js +12 -22
- package/dist/tests/translations.unit.test.js.map +1 -1
- package/dist/tests/translator.unit.test.d.ts +2 -0
- package/dist/tests/translator.unit.test.d.ts.map +1 -0
- package/dist/tests/translator.unit.test.js +134 -0
- package/dist/tests/translator.unit.test.js.map +1 -0
- package/dist/translationKey.d.ts +2 -3
- package/dist/translationKey.d.ts.map +1 -1
- package/dist/translationKey.js +2 -3
- package/dist/translationKey.js.map +1 -1
- package/dist/translations.d.ts +3 -2
- package/dist/translations.d.ts.map +1 -1
- package/dist/translations.js +3 -3
- package/dist/translations.js.map +1 -1
- package/dist/translator.d.ts +9 -0
- package/dist/translator.d.ts.map +1 -0
- package/dist/translator.js +29 -0
- package/dist/translator.js.map +1 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +4 -9
- package/src/i18SharedOptions.ts +3 -3
- package/src/i18nFrontend.ts +240 -78
- package/src/i18nMiddleware.ts +6 -1
- package/src/index.ts +11 -4
- package/src/loadI18next.ts +8 -9
- 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/tests/i18nFrontend.unit.test.ts +310 -219
- package/src/tests/i18nMiddleware.unit.test.ts +21 -2
- package/src/tests/loadI18next.unit.test.ts +11 -11
- package/src/tests/locale.test-d.ts +36 -32
- package/src/tests/translationKey.unit.test.ts +12 -38
- package/src/tests/translations.unit.test.ts +18 -31
- package/src/tests/translator.unit.test.ts +213 -0
- package/src/translationKey.ts +10 -9
- package/src/translations.ts +6 -5
- package/src/translator.ts +68 -0
- package/src/types.ts +42 -0
- package/tsconfig.cjs.json +3 -0
- package/tsconfig.json +3 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/useTranslation.cjs +0 -12
- package/dist/tests/useTranslation.unit.test.d.ts +0 -2
- package/dist/tests/useTranslation.unit.test.d.ts.map +0 -1
- package/dist/tests/useTranslation.unit.test.js +0 -81
- package/dist/tests/useTranslation.unit.test.js.map +0 -1
- package/dist/useTranslation.d.ts +0 -7
- package/dist/useTranslation.d.ts.map +0 -1
- package/dist/useTranslation.js +0 -11
- package/dist/useTranslation.js.map +0 -1
- package/src/tests/useTranslation.unit.test.ts +0 -134
- package/src/useTranslation.ts +0 -31
|
@@ -25,6 +25,8 @@ interface Harness {
|
|
|
25
25
|
instance: i18n;
|
|
26
26
|
handleCalls: [i18n, HandleOptions | undefined][];
|
|
27
27
|
handler: Handler;
|
|
28
|
+
/** True once loadI18next has resolved — handle() must not run before then. */
|
|
29
|
+
loaded: () => boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const load = async (options: {
|
|
@@ -38,6 +40,11 @@ const load = async (options: {
|
|
|
38
40
|
const instance = asI18n({ name: "the singleton" });
|
|
39
41
|
const handleCalls: [i18n, HandleOptions | undefined][] = [];
|
|
40
42
|
const handler = ((): void => {}) as unknown as Handler;
|
|
43
|
+
let loaded = false;
|
|
44
|
+
|
|
45
|
+
// The middleware takes the i18next singleton directly: handle() needs the
|
|
46
|
+
// real instance, not the narrow Ti18n loadI18next hands back.
|
|
47
|
+
vi.doMock("i18next", () => ({ default: instance }));
|
|
41
48
|
|
|
42
49
|
vi.doMock("i18next-http-middleware", () => ({
|
|
43
50
|
handle: (i18next: i18n, opts?: HandleOptions): Handler => {
|
|
@@ -50,6 +57,7 @@ const load = async (options: {
|
|
|
50
57
|
if (options.loadRejects) {
|
|
51
58
|
throw new Error("i18n failed to load");
|
|
52
59
|
}
|
|
60
|
+
loaded = true;
|
|
53
61
|
return instance;
|
|
54
62
|
},
|
|
55
63
|
}));
|
|
@@ -57,7 +65,7 @@ const load = async (options: {
|
|
|
57
65
|
const imported = await import("../i18nMiddleware.js");
|
|
58
66
|
return {
|
|
59
67
|
i18nMiddleware: imported.default,
|
|
60
|
-
harness: { instance, handleCalls, handler },
|
|
68
|
+
harness: { instance, handleCalls, handler, loaded: (): boolean => loaded },
|
|
61
69
|
};
|
|
62
70
|
};
|
|
63
71
|
|
|
@@ -65,10 +73,11 @@ afterEach(() => {
|
|
|
65
73
|
vi.resetModules();
|
|
66
74
|
vi.doUnmock("i18next-http-middleware");
|
|
67
75
|
vi.doUnmock("../loadI18next.js");
|
|
76
|
+
vi.doUnmock("i18next");
|
|
68
77
|
});
|
|
69
78
|
|
|
70
79
|
describe("i18nMiddleware", () => {
|
|
71
|
-
test("builds the handler from the
|
|
80
|
+
test("builds the handler from the i18next singleton", async () => {
|
|
72
81
|
const { i18nMiddleware, harness } = await load({});
|
|
73
82
|
|
|
74
83
|
const handler = await i18nMiddleware({});
|
|
@@ -78,6 +87,16 @@ describe("i18nMiddleware", () => {
|
|
|
78
87
|
expect(handler).toBe(harness.handler);
|
|
79
88
|
});
|
|
80
89
|
|
|
90
|
+
test("waits for the resources before handing the instance over", async () => {
|
|
91
|
+
// handle() reads the instance's language list when it is built, so an
|
|
92
|
+
// uninitialised singleton would produce a middleware that detects nothing.
|
|
93
|
+
const { i18nMiddleware, harness } = await load({});
|
|
94
|
+
|
|
95
|
+
await i18nMiddleware({});
|
|
96
|
+
|
|
97
|
+
expect(harness.loaded()).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
81
100
|
test("forwards the caller's options", async () => {
|
|
82
101
|
const { i18nMiddleware, harness } = await load({});
|
|
83
102
|
const options: HandleOptions = { ignoreRoutes: ["/healthz"] };
|
|
@@ -12,27 +12,27 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import type { i18n } from "i18next";
|
|
16
15
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
16
|
+
import type { Ti18n } from "../types.js";
|
|
17
17
|
|
|
18
18
|
// loadI18next keeps a module-level singleton, so every test re-imports it
|
|
19
|
-
// under a fresh registry. The two i18n implementations are mocked out:
|
|
20
|
-
//
|
|
21
|
-
// own logic
|
|
19
|
+
// under a fresh registry. The two i18n implementations are mocked out: one
|
|
20
|
+
// fetches catalogues over the network, the other pulls in i18next and the
|
|
21
|
+
// filesystem, and neither is what this module's own logic does.
|
|
22
22
|
|
|
23
|
-
/** The slice of
|
|
23
|
+
/** The slice of an i18n instance loadI18next actually touches. */
|
|
24
24
|
interface FakeI18n {
|
|
25
25
|
language: string;
|
|
26
26
|
changeLanguage: (lng: string) => Promise<unknown>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** One cast, confined here, so the mocked module keeps the real signature. */
|
|
30
|
-
const asI18n = (fake: FakeI18n):
|
|
30
|
+
const asI18n = (fake: FakeI18n): Ti18n => fake as unknown as Ti18n;
|
|
31
31
|
|
|
32
32
|
type InitializeI18n = (
|
|
33
|
-
callback?: (instance:
|
|
33
|
+
callback?: (instance: Ti18n) => void,
|
|
34
34
|
lng?: string,
|
|
35
|
-
) =>
|
|
35
|
+
) => Ti18n;
|
|
36
36
|
|
|
37
37
|
interface Harness {
|
|
38
38
|
instance: FakeI18n;
|
|
@@ -79,7 +79,7 @@ const install = (
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
const loadModule = async (): Promise<
|
|
82
|
-
(backend: boolean, lng?: string) => Promise<
|
|
82
|
+
(backend: boolean, lng?: string) => Promise<Ti18n>
|
|
83
83
|
> => {
|
|
84
84
|
const imported = await import("../loadI18next.js");
|
|
85
85
|
return imported.default;
|
|
@@ -352,7 +352,7 @@ describe("loadI18next, timeout", () => {
|
|
|
352
352
|
// Without the one-shot latch a later emission would call resolve again
|
|
353
353
|
// and leave the timer armed.
|
|
354
354
|
vi.useFakeTimers();
|
|
355
|
-
let fire: ((instance:
|
|
355
|
+
let fire: ((instance: Ti18n) => void) | undefined;
|
|
356
356
|
const instance: FakeI18n = {
|
|
357
357
|
language: "en",
|
|
358
358
|
changeLanguage: async (): Promise<unknown> => undefined,
|
|
@@ -382,7 +382,7 @@ describe("loadI18next, timeout", () => {
|
|
|
382
382
|
// the degraded instance. Calling reject then would produce an unhandled
|
|
383
383
|
// rejection on an already-settled promise.
|
|
384
384
|
vi.useFakeTimers();
|
|
385
|
-
let fire: ((instance:
|
|
385
|
+
let fire: ((instance: Ti18n) => void) | undefined;
|
|
386
386
|
const instance: FakeI18n = {
|
|
387
387
|
language: "en",
|
|
388
388
|
changeLanguage: async (): Promise<unknown> => {
|
|
@@ -18,22 +18,24 @@
|
|
|
18
18
|
|
|
19
19
|
import { assertType, describe, expectTypeOf, it } from "vitest";
|
|
20
20
|
import {
|
|
21
|
-
|
|
21
|
+
type Language,
|
|
22
|
+
LanguageCodes,
|
|
22
23
|
Languages,
|
|
23
24
|
type Ti18n,
|
|
24
25
|
type TranslationKey,
|
|
25
|
-
|
|
26
|
+
createTranslator,
|
|
26
27
|
i18nMiddleware,
|
|
27
28
|
isClientSide,
|
|
29
|
+
isLanguage,
|
|
28
30
|
loadI18next,
|
|
29
|
-
|
|
31
|
+
translationKeys,
|
|
30
32
|
} from "../index.js";
|
|
31
33
|
|
|
32
34
|
describe("Languages", () => {
|
|
33
35
|
it("is a frozen const map of name to code, not a widened record", () => {
|
|
34
|
-
// `as const` is what lets
|
|
35
|
-
// to Record<string, string> would silently
|
|
36
|
-
//
|
|
36
|
+
// `as const` is what lets `Language` be the union of the codes.
|
|
37
|
+
// Widening this to Record<string, string> would silently reduce it to
|
|
38
|
+
// `string`.
|
|
37
39
|
expectTypeOf(Languages.english).toEqualTypeOf<"en">();
|
|
38
40
|
expectTypeOf(Languages.chinese).toEqualTypeOf<"zh-CN">();
|
|
39
41
|
});
|
|
@@ -49,28 +51,24 @@ describe("Languages", () => {
|
|
|
49
51
|
});
|
|
50
52
|
});
|
|
51
53
|
|
|
52
|
-
describe("
|
|
53
|
-
it("
|
|
54
|
-
|
|
55
|
-
// type is string rather than the union of codes. Pinned deliberately:
|
|
56
|
-
// a consumer must not assume narrowing it does not get.
|
|
57
|
-
expectTypeOf(LanguageSchema.parse("en")).toEqualTypeOf<string>();
|
|
54
|
+
describe("isLanguage", () => {
|
|
55
|
+
it("narrows an arbitrary string to a declared code", () => {
|
|
56
|
+
expectTypeOf(isLanguage).guards.toEqualTypeOf<Language>();
|
|
58
57
|
});
|
|
59
58
|
|
|
60
|
-
it("
|
|
61
|
-
expectTypeOf(
|
|
59
|
+
it("accepts any string at the boundary — input is untrusted markup", () => {
|
|
60
|
+
expectTypeOf(isLanguage).toBeCallableWith("anything");
|
|
62
61
|
});
|
|
63
62
|
|
|
64
|
-
it("
|
|
65
|
-
expectTypeOf(
|
|
63
|
+
it("is the union of the declared codes, not a widened string", () => {
|
|
64
|
+
expectTypeOf<Language>().toExtend<string>();
|
|
65
|
+
assertType<Language>("en");
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
describe("TranslationKey", () => {
|
|
70
|
-
it("is
|
|
71
|
-
expectTypeOf<TranslationKey>().toEqualTypeOf<
|
|
72
|
-
ReturnType<typeof TranslationKeysSchema.parse>
|
|
73
|
-
>();
|
|
70
|
+
it("is a plain string — keys are read from JSON, never narrowed", () => {
|
|
71
|
+
expectTypeOf<TranslationKey>().toEqualTypeOf<string>();
|
|
74
72
|
});
|
|
75
73
|
|
|
76
74
|
it("accepts a key literal, so error call sites need no cast", () => {
|
|
@@ -79,10 +77,13 @@ describe("TranslationKey", () => {
|
|
|
79
77
|
assertType<TranslationKey>("API.UNKNOWN");
|
|
80
78
|
});
|
|
81
79
|
|
|
82
|
-
it("exposes the derived keys as an array
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
it("exposes the derived keys as an array", () => {
|
|
81
|
+
// What the mongoose schemas in types-database put in their `enum` fields.
|
|
82
|
+
expectTypeOf(translationKeys).toExtend<readonly string[]>();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("exposes the declared language codes as an array", () => {
|
|
86
|
+
expectTypeOf(LanguageCodes).toExtend<readonly string[]>();
|
|
86
87
|
});
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -140,17 +141,20 @@ describe("i18nMiddleware", () => {
|
|
|
140
141
|
});
|
|
141
142
|
});
|
|
142
143
|
|
|
143
|
-
describe("
|
|
144
|
-
it("is callable with no
|
|
145
|
-
expectTypeOf(
|
|
144
|
+
describe("createTranslator", () => {
|
|
145
|
+
it("is callable with no instance, so a standalone widget can boot one", () => {
|
|
146
|
+
expectTypeOf(createTranslator).toBeCallableWith();
|
|
146
147
|
});
|
|
147
148
|
|
|
148
|
-
it("returns
|
|
149
|
-
expectTypeOf(
|
|
149
|
+
it("returns the translator surface the widgets render against", () => {
|
|
150
|
+
expectTypeOf(createTranslator).returns.toHaveProperty("t");
|
|
151
|
+
expectTypeOf(createTranslator).returns.toHaveProperty("isReady");
|
|
152
|
+
expectTypeOf(createTranslator).returns.toHaveProperty("subscribe");
|
|
153
|
+
expectTypeOf(createTranslator).returns.toHaveProperty("i18n");
|
|
150
154
|
});
|
|
151
155
|
|
|
152
|
-
it("rejects an
|
|
153
|
-
// @ts-expect-error
|
|
154
|
-
|
|
156
|
+
it("rejects anything that is not an i18next instance", () => {
|
|
157
|
+
// @ts-expect-error the only parameter is an existing i18next instance
|
|
158
|
+
createTranslator({ notAnInstance: 1 });
|
|
155
159
|
});
|
|
156
160
|
});
|
|
@@ -17,9 +17,9 @@ import translationEn from "../locales/en/translation.json" with {
|
|
|
17
17
|
type: "json",
|
|
18
18
|
};
|
|
19
19
|
import {
|
|
20
|
-
TranslationKeysSchema,
|
|
21
20
|
type TranslationNode,
|
|
22
21
|
getLeafFieldPath,
|
|
22
|
+
translationKeys,
|
|
23
23
|
} from "../translationKey.js";
|
|
24
24
|
|
|
25
25
|
describe("getLeafFieldPath", () => {
|
|
@@ -34,7 +34,7 @@ describe("getLeafFieldPath", () => {
|
|
|
34
34
|
// The string is the leaf, so the path ends at its key. This used to yield
|
|
35
35
|
// [] — the recursion returned nothing for a string and the parent mapped
|
|
36
36
|
// over that empty list — which made the whole function return [] for every
|
|
37
|
-
// input and left
|
|
37
|
+
// input and left `translationKeys` empty.
|
|
38
38
|
test("emits a top-level string value under its own key", () => {
|
|
39
39
|
expect(getLeafFieldPath({ topLevel: "value" })).toEqual(["topLevel"]);
|
|
40
40
|
});
|
|
@@ -112,55 +112,29 @@ describe("getLeafFieldPath", () => {
|
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
describe("
|
|
115
|
+
describe("translationKeys", () => {
|
|
116
116
|
const keys = getLeafFieldPath(translationEn as TranslationNode);
|
|
117
117
|
|
|
118
|
-
test("is non-empty — an empty
|
|
118
|
+
test("is non-empty — mongoose validates against an empty enum too", () => {
|
|
119
119
|
expect(keys.length).toBeGreaterThan(0);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
test("
|
|
123
|
-
|
|
124
|
-
expect(TranslationKeysSchema.parse(key)).toBe(key);
|
|
125
|
-
}
|
|
122
|
+
test("is exactly the set of keys derived from the English translation", () => {
|
|
123
|
+
expect(new Set(translationKeys)).toEqual(new Set(keys));
|
|
126
124
|
});
|
|
127
125
|
|
|
128
|
-
test("
|
|
129
|
-
expect(() => TranslationKeysSchema.parse("no.such.key")).toThrow();
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("rejects the empty string", () => {
|
|
133
|
-
expect(() => TranslationKeysSchema.parse("")).toThrow();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("rejects a section name on its own — only leaves are valid keys", () => {
|
|
126
|
+
test("holds leaves, not section names", () => {
|
|
137
127
|
const [firstKey] = keys;
|
|
138
128
|
expect(firstKey).toBeDefined();
|
|
139
129
|
const section = String(firstKey).split(".")[0];
|
|
140
|
-
expect(
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test("rejects non-string input", () => {
|
|
144
|
-
expect(() => TranslationKeysSchema.parse(42)).toThrow();
|
|
145
|
-
expect(() => TranslationKeysSchema.parse(null)).toThrow();
|
|
146
|
-
expect(() => TranslationKeysSchema.parse(undefined)).toThrow();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("safeParse reports failure without throwing", () => {
|
|
150
|
-
const result = TranslationKeysSchema.safeParse("no.such.key");
|
|
151
|
-
expect(result.success).toBe(false);
|
|
130
|
+
expect(translationKeys).not.toContain(section);
|
|
152
131
|
});
|
|
153
132
|
|
|
154
|
-
test("
|
|
155
|
-
|
|
156
|
-
expect(firstKey).toBeDefined();
|
|
157
|
-
const upper = String(firstKey).toUpperCase();
|
|
158
|
-
if (upper !== firstKey) {
|
|
159
|
-
expect(() => TranslationKeysSchema.parse(upper)).toThrow();
|
|
160
|
-
}
|
|
133
|
+
test("does not contain a key absent from the English translation", () => {
|
|
134
|
+
expect(translationKeys).not.toContain("no.such.key");
|
|
161
135
|
});
|
|
162
136
|
|
|
163
|
-
test("
|
|
164
|
-
expect(
|
|
137
|
+
test("does not contain the empty string", () => {
|
|
138
|
+
expect(translationKeys).not.toContain("");
|
|
165
139
|
});
|
|
166
140
|
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { describe, expect, test } from "vitest";
|
|
18
|
-
import {
|
|
18
|
+
import { LanguageCodes, Languages, isLanguage } from "../translations.js";
|
|
19
19
|
|
|
20
20
|
const codes: string[] = Object.values(Languages);
|
|
21
21
|
|
|
@@ -34,9 +34,8 @@ describe("Languages", () => {
|
|
|
34
34
|
expect(codes).toContain("en");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
// A duplicated code would silently
|
|
38
|
-
//
|
|
39
|
-
// test would still pass.
|
|
37
|
+
// A duplicated code would silently make one of the language names
|
|
38
|
+
// unreachable, while the locales-folder count test would still pass.
|
|
40
39
|
test("has no duplicate codes", () => {
|
|
41
40
|
expect(new Set(codes).size).toBe(codes.length);
|
|
42
41
|
});
|
|
@@ -62,49 +61,37 @@ describe("Languages", () => {
|
|
|
62
61
|
});
|
|
63
62
|
});
|
|
64
63
|
|
|
65
|
-
describe("
|
|
64
|
+
describe("isLanguage", () => {
|
|
66
65
|
test("accepts every declared code", () => {
|
|
67
66
|
for (const code of codes) {
|
|
68
|
-
expect(
|
|
67
|
+
expect(isLanguage(code)).toBe(true);
|
|
69
68
|
}
|
|
70
69
|
});
|
|
71
70
|
|
|
72
|
-
test("
|
|
73
|
-
expect(
|
|
71
|
+
test("every declared code is in LanguageCodes", () => {
|
|
72
|
+
expect(new Set(LanguageCodes)).toEqual(new Set(codes));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("exposes each code as a constant for use in code", () => {
|
|
76
|
+
expect(Languages.english).toBe("en");
|
|
74
77
|
});
|
|
75
78
|
|
|
76
79
|
test("rejects an undeclared language code", () => {
|
|
77
|
-
expect((
|
|
80
|
+
expect(isLanguage("xx")).toBe(false);
|
|
78
81
|
});
|
|
79
82
|
|
|
80
83
|
test("rejects the empty string", () => {
|
|
81
|
-
expect((
|
|
84
|
+
expect(isLanguage("")).toBe(false);
|
|
82
85
|
});
|
|
83
86
|
|
|
84
|
-
// i18next
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
+
// i18next treated `en-GB` as a region of `en`, and the browser i18n resolves
|
|
88
|
+
// a regional tag to its base language before asking — so a regional variant
|
|
89
|
+
// is deliberately not itself a declared code.
|
|
87
90
|
test("rejects a regional variant of a supported language", () => {
|
|
88
|
-
expect((
|
|
91
|
+
expect(isLanguage("en-GB")).toBe(false);
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
test("is case sensitive — `EN` is not `en`", () => {
|
|
92
|
-
expect((
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("rejects non-string input", () => {
|
|
96
|
-
expect(() => LanguageSchema.parse(1)).toThrow();
|
|
97
|
-
expect(() => LanguageSchema.parse(null)).toThrow();
|
|
98
|
-
expect(() => LanguageSchema.parse(undefined)).toThrow();
|
|
99
|
-
expect(() => LanguageSchema.parse(["en"])).toThrow();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("safeParse reports failure without throwing", () => {
|
|
103
|
-
expect(LanguageSchema.safeParse("xx").success).toBe(false);
|
|
104
|
-
expect(LanguageSchema.safeParse("en").success).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("its options are exactly the declared codes", () => {
|
|
108
|
-
expect(new Set(LanguageSchema.options)).toEqual(new Set(codes));
|
|
95
|
+
expect(isLanguage("EN")).toBe(false);
|
|
109
96
|
});
|
|
110
97
|
});
|
|
@@ -0,0 +1,213 @@
|
|
|
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 { afterEach, describe, expect, test, vi } from "vitest";
|
|
17
|
+
import type { Translator } from "../translator.js";
|
|
18
|
+
|
|
19
|
+
// The translator is a thin wrapper: it forces the package's own i18next
|
|
20
|
+
// instance in rather than letting a host app's provider decide, and exposes the
|
|
21
|
+
// three events that used to drive a React re-render. i18next itself is mocked
|
|
22
|
+
// so the tests observe the wiring rather than i18next's own behaviour.
|
|
23
|
+
|
|
24
|
+
interface FakeI18n {
|
|
25
|
+
readonly instance: i18n;
|
|
26
|
+
readonly handlers: Map<string, Set<() => void>>;
|
|
27
|
+
isInitialized: boolean;
|
|
28
|
+
loadedNamespaces: string[];
|
|
29
|
+
translations: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fakeI18n = (): FakeI18n => {
|
|
33
|
+
const handlers = new Map<string, Set<() => void>>();
|
|
34
|
+
const fake: FakeI18n = {
|
|
35
|
+
handlers,
|
|
36
|
+
isInitialized: true,
|
|
37
|
+
loadedNamespaces: ["translation"],
|
|
38
|
+
translations: {},
|
|
39
|
+
instance: {
|
|
40
|
+
t: (key: string): string => fake.translations[key] ?? key,
|
|
41
|
+
get isInitialized(): boolean {
|
|
42
|
+
return fake.isInitialized;
|
|
43
|
+
},
|
|
44
|
+
hasLoadedNamespace: (namespace: string): boolean =>
|
|
45
|
+
fake.loadedNamespaces.includes(namespace),
|
|
46
|
+
on: (event: string, listener: () => void): void => {
|
|
47
|
+
const existing = handlers.get(event) ?? new Set<() => void>();
|
|
48
|
+
existing.add(listener);
|
|
49
|
+
handlers.set(event, existing);
|
|
50
|
+
},
|
|
51
|
+
off: (event: string, listener: () => void): void => {
|
|
52
|
+
handlers.get(event)?.delete(listener);
|
|
53
|
+
},
|
|
54
|
+
} as unknown as i18n,
|
|
55
|
+
};
|
|
56
|
+
return fake;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const emit = (fake: FakeI18n, event: string): void => {
|
|
60
|
+
for (const listener of fake.handlers.get(event) ?? []) {
|
|
61
|
+
listener();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const load = async (): Promise<{
|
|
66
|
+
createTranslator: (existing?: i18n) => Translator;
|
|
67
|
+
packageInstance: i18n;
|
|
68
|
+
initCount: () => number;
|
|
69
|
+
}> => {
|
|
70
|
+
vi.resetModules();
|
|
71
|
+
|
|
72
|
+
const packageInstance = fakeI18n().instance;
|
|
73
|
+
let initCalls = 0;
|
|
74
|
+
|
|
75
|
+
vi.doMock("../i18nFrontend.js", () => ({
|
|
76
|
+
default: (): i18n => {
|
|
77
|
+
initCalls += 1;
|
|
78
|
+
return packageInstance;
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const imported = await import("../translator.js");
|
|
83
|
+
return {
|
|
84
|
+
createTranslator: imported.createTranslator,
|
|
85
|
+
packageInstance,
|
|
86
|
+
initCount: (): number => initCalls,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
vi.resetModules();
|
|
92
|
+
vi.doUnmock("../i18nFrontend.js");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("which i18next instance the translator talks to", () => {
|
|
96
|
+
test("initialises the package's own instance when given none", async () => {
|
|
97
|
+
// Without this, a host application running its own i18next would capture
|
|
98
|
+
// the widget's translations and render its keys instead.
|
|
99
|
+
const { createTranslator, packageInstance, initCount } = await load();
|
|
100
|
+
|
|
101
|
+
const translator = createTranslator();
|
|
102
|
+
|
|
103
|
+
expect(translator.i18n).toBe(packageInstance);
|
|
104
|
+
expect(initCount()).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("adopts an instance the caller already has", async () => {
|
|
108
|
+
// The bundle loads i18next once and hands the same instance to every
|
|
109
|
+
// widget on the page, so a second init would be wasted work.
|
|
110
|
+
const { createTranslator, initCount } = await load();
|
|
111
|
+
const existing = fakeI18n().instance;
|
|
112
|
+
|
|
113
|
+
const translator = createTranslator(existing);
|
|
114
|
+
|
|
115
|
+
expect(translator.i18n).toBe(existing);
|
|
116
|
+
expect(initCount()).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("translating", () => {
|
|
121
|
+
test("delegates to the instance", async () => {
|
|
122
|
+
const { createTranslator } = await load();
|
|
123
|
+
const fake = fakeI18n();
|
|
124
|
+
fake.translations = { "WIDGET.SUBMIT": "Submit" };
|
|
125
|
+
|
|
126
|
+
expect(createTranslator(fake.instance).t("WIDGET.SUBMIT")).toBe("Submit");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("reads the instance at call time, not at creation time", async () => {
|
|
130
|
+
// A language switch mutates the instance in place; a translator that
|
|
131
|
+
// cached `t` would keep serving the previous language.
|
|
132
|
+
const { createTranslator } = await load();
|
|
133
|
+
const fake = fakeI18n();
|
|
134
|
+
const translator = createTranslator(fake.instance);
|
|
135
|
+
|
|
136
|
+
fake.translations = { "WIDGET.SUBMIT": "Envoyer" };
|
|
137
|
+
|
|
138
|
+
expect(translator.t("WIDGET.SUBMIT")).toBe("Envoyer");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("readiness", () => {
|
|
143
|
+
test("is ready once the instance has loaded the translation namespace", async () => {
|
|
144
|
+
const { createTranslator } = await load();
|
|
145
|
+
const fake = fakeI18n();
|
|
146
|
+
|
|
147
|
+
expect(createTranslator(fake.instance).isReady()).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("is not ready before initialisation", async () => {
|
|
151
|
+
const { createTranslator } = await load();
|
|
152
|
+
const fake = fakeI18n();
|
|
153
|
+
fake.isInitialized = false;
|
|
154
|
+
|
|
155
|
+
expect(createTranslator(fake.instance).isReady()).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("is not ready while the namespace is still loading", async () => {
|
|
159
|
+
// Widgets render an empty label until this flips, rather than flashing a
|
|
160
|
+
// raw translation key at the user.
|
|
161
|
+
const { createTranslator } = await load();
|
|
162
|
+
const fake = fakeI18n();
|
|
163
|
+
fake.loadedNamespaces = [];
|
|
164
|
+
|
|
165
|
+
expect(createTranslator(fake.instance).isReady()).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("subscribing", () => {
|
|
170
|
+
test.each(["initialized", "loaded", "languageChanged"])(
|
|
171
|
+
"notifies the listener on %s",
|
|
172
|
+
async (event: string) => {
|
|
173
|
+
const { createTranslator } = await load();
|
|
174
|
+
const fake = fakeI18n();
|
|
175
|
+
const listener = vi.fn<() => void>();
|
|
176
|
+
|
|
177
|
+
createTranslator(fake.instance).subscribe(listener);
|
|
178
|
+
emit(fake, event);
|
|
179
|
+
|
|
180
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
test("stops notifying once unsubscribed", async () => {
|
|
185
|
+
// Widgets unsubscribe on destroy; a leaked listener would keep a torn-down
|
|
186
|
+
// widget alive for the lifetime of the page.
|
|
187
|
+
const { createTranslator } = await load();
|
|
188
|
+
const fake = fakeI18n();
|
|
189
|
+
const listener = vi.fn<() => void>();
|
|
190
|
+
|
|
191
|
+
const unsubscribe = createTranslator(fake.instance).subscribe(listener);
|
|
192
|
+
unsubscribe();
|
|
193
|
+
emit(fake, "languageChanged");
|
|
194
|
+
|
|
195
|
+
expect(listener).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("keeps independent subscriptions apart", async () => {
|
|
199
|
+
const { createTranslator } = await load();
|
|
200
|
+
const fake = fakeI18n();
|
|
201
|
+
const first = vi.fn<() => void>();
|
|
202
|
+
const second = vi.fn<() => void>();
|
|
203
|
+
const translator = createTranslator(fake.instance);
|
|
204
|
+
|
|
205
|
+
const unsubscribeFirst = translator.subscribe(first);
|
|
206
|
+
translator.subscribe(second);
|
|
207
|
+
unsubscribeFirst();
|
|
208
|
+
emit(fake, "loaded");
|
|
209
|
+
|
|
210
|
+
expect(first).not.toHaveBeenCalled();
|
|
211
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
package/src/translationKey.ts
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import { z } from "zod";
|
|
16
15
|
import translationEn from "./locales/en/translation.json" with { type: "json" };
|
|
17
16
|
|
|
18
17
|
export type TranslationNode =
|
|
@@ -38,10 +37,10 @@ export function getLeafFieldPath(obj: TranslationNode): string[] {
|
|
|
38
37
|
// A string value IS the leaf, so the path ends here. Recursing into it
|
|
39
38
|
// returns [] and the `children.map` below then contributes nothing,
|
|
40
39
|
// which is how this function used to return an empty array for every
|
|
41
|
-
// input — leaving
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
40
|
+
// input — leaving `translationKeys` empty. That list is spread into
|
|
41
|
+
// three mongoose `reason` fields (types-database provider.ts), and
|
|
42
|
+
// mongoose registers its enum validator even for an empty list, so
|
|
43
|
+
// every non-null reason failed validation.
|
|
45
44
|
if (typeof value === "string") {
|
|
46
45
|
return arr.concat(key);
|
|
47
46
|
}
|
|
@@ -56,8 +55,10 @@ export function getLeafFieldPath(obj: TranslationNode): string[] {
|
|
|
56
55
|
}, [] as string[]);
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
export const
|
|
60
|
-
getLeafFieldPath(translationEn) as [string, ...string[]],
|
|
61
|
-
);
|
|
58
|
+
export const translationKeys: string[] = getLeafFieldPath(translationEn);
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Any leaf path in the English catalogue. Not narrowed to those paths: they are
|
|
62
|
+
* read from JSON at runtime, so the compiler only ever knew them as `string`.
|
|
63
|
+
*/
|
|
64
|
+
export type TranslationKey = string;
|