@prosopo/locale 3.5.0 → 3.6.1
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 +14 -12
- package/.turbo/turbo-build$colon$tsc.log +8 -5
- package/.turbo/turbo-build.log +16 -14
- package/CHANGELOG.md +54 -0
- package/dist/cjs/i18SharedOptions.cjs +2 -2
- package/dist/cjs/i18nBackend.cjs +1 -1
- package/dist/cjs/i18nFrontend.cjs +169 -51
- package/dist/cjs/i18nMiddleware.cjs +5 -2
- package/dist/cjs/index.cjs +3 -2
- package/dist/cjs/translationKey.cjs +2 -3
- package/dist/cjs/translations.cjs +4 -5
- package/dist/cjs/translator.cjs +2 -3
- 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 +169 -45
- 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 +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- 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/fi/translation.json +1 -1
- package/dist/locales/hi/translation.json +406 -406
- package/dist/locales/nl/translation.json +1 -1
- package/dist/tests/i18nFrontend.unit.test.js +218 -170
- 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 +15 -11
- package/dist/tests/locale.test-d.js.map +1 -1
- package/dist/tests/localePlaceholders.unit.test.d.ts +2 -0
- package/dist/tests/localePlaceholders.unit.test.d.ts.map +1 -0
- package/dist/tests/localePlaceholders.unit.test.js +35 -0
- package/dist/tests/localePlaceholders.unit.test.js.map +1 -0
- 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/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 +2 -3
- package/dist/translator.d.ts.map +1 -1
- package/dist/translator.js +2 -3
- package/dist/translator.js.map +1 -1
- 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 -8
- package/src/i18SharedOptions.ts +3 -3
- package/src/i18nFrontend.ts +240 -73
- package/src/i18nMiddleware.ts +6 -1
- package/src/index.ts +9 -3
- package/src/loadI18next.ts +7 -8
- package/src/locales/fi/translation.json +1 -1
- package/src/locales/hi/translation.json +406 -406
- package/src/locales/nl/translation.json +1 -1
- package/src/tests/i18nFrontend.unit.test.ts +310 -216
- 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 +24 -23
- package/src/tests/localePlaceholders.unit.test.ts +81 -0
- package/src/tests/translationKey.unit.test.ts +12 -38
- package/src/tests/translations.unit.test.ts +18 -31
- package/src/translationKey.ts +10 -9
- package/src/translations.ts +6 -5
- package/src/translator.ts +6 -7
- package/src/types.ts +42 -0
- package/tsconfig.cjs.json +3 -0
- package/tsconfig.json +3 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
-
TranslationKeysSchema,
|
|
26
26
|
createTranslator,
|
|
27
27
|
i18nMiddleware,
|
|
28
28
|
isClientSide,
|
|
29
|
+
isLanguage,
|
|
29
30
|
loadI18next,
|
|
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
|
|
|
@@ -0,0 +1,81 @@
|
|
|
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 fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { Languages } from "../index.js";
|
|
19
|
+
|
|
20
|
+
type TranslationTree = { [key: string]: string | TranslationTree };
|
|
21
|
+
|
|
22
|
+
const readLocale = (locale: string): TranslationTree =>
|
|
23
|
+
JSON.parse(
|
|
24
|
+
fs.readFileSync(
|
|
25
|
+
path.resolve(`./src/locales/${locale}/translation.json`),
|
|
26
|
+
"utf8",
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const flatten = (tree: TranslationTree, prefix = ""): Array<[string, string]> =>
|
|
31
|
+
Object.entries(tree).flatMap(
|
|
32
|
+
([key, value]): Array<[string, string]> =>
|
|
33
|
+
typeof value === "string"
|
|
34
|
+
? [[`${prefix}${key}`, value]]
|
|
35
|
+
: flatten(value, `${prefix}${key}.`),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const placeholders = (text: string): string[] =>
|
|
39
|
+
(text.match(/\{\{[^}]*\}\}/g) ?? [])
|
|
40
|
+
.map((token: string) => token.replace(/\s+/g, ""))
|
|
41
|
+
.sort();
|
|
42
|
+
|
|
43
|
+
const en = new Map<string, string>(flatten(readLocale("en")));
|
|
44
|
+
const locales: string[] = Object.values(Languages).filter(
|
|
45
|
+
(locale: string) => locale !== "en",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
describe("locale interpolation placeholders", () => {
|
|
49
|
+
it.each(locales)(
|
|
50
|
+
"%s keeps every {{placeholder}} of the en source",
|
|
51
|
+
(locale: string) => {
|
|
52
|
+
const mismatches: string[] = flatten(readLocale(locale))
|
|
53
|
+
.filter(
|
|
54
|
+
([key, value]: [string, string]) =>
|
|
55
|
+
en.has(key) &&
|
|
56
|
+
placeholders(value).join() !==
|
|
57
|
+
placeholders(en.get(key) ?? "").join(),
|
|
58
|
+
)
|
|
59
|
+
.map(([key, value]: [string, string]) => `${key}: ${value}`);
|
|
60
|
+
expect(mismatches).toEqual([]);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A tool once stripped every Devanagari vowel sign and virama from the Hindi
|
|
66
|
+
// file ("मैं मनुष्य हूँ" became "म मनषय ह"), which the key and placeholder
|
|
67
|
+
// checks cannot see.
|
|
68
|
+
describe("hi translations", () => {
|
|
69
|
+
it("keep their Devanagari vowel signs", () => {
|
|
70
|
+
const devanagariLetter = /[ऄ-हक़-ॡ]/gu;
|
|
71
|
+
const sign = /[ऀ-ःऺ-ॏॕ-ॗॢॣ]/u;
|
|
72
|
+
const stripped: string[] = flatten(readLocale("hi"))
|
|
73
|
+
.filter(
|
|
74
|
+
([, value]: [string, string]) =>
|
|
75
|
+
(value.match(devanagariLetter) ?? []).length >= 4 &&
|
|
76
|
+
!sign.test(value),
|
|
77
|
+
)
|
|
78
|
+
.map(([key, value]: [string, string]) => `${key}: ${value}`);
|
|
79
|
+
expect(stripped).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -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
|
});
|
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;
|
package/src/translations.ts
CHANGED
|
@@ -12,8 +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
|
-
|
|
17
15
|
export const Languages = {
|
|
18
16
|
arabic: "ar",
|
|
19
17
|
azerbaijani: "az",
|
|
@@ -49,6 +47,9 @@ export const Languages = {
|
|
|
49
47
|
chinese: "zh-CN",
|
|
50
48
|
} as const;
|
|
51
49
|
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
);
|
|
50
|
+
export type Language = (typeof Languages)[keyof typeof Languages];
|
|
51
|
+
|
|
52
|
+
export const LanguageCodes: readonly string[] = Object.values(Languages);
|
|
53
|
+
|
|
54
|
+
export const isLanguage = (value: string): value is Language =>
|
|
55
|
+
LanguageCodes.includes(value);
|
package/src/translator.ts
CHANGED
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import type { TOptions } from "i18next";
|
|
16
15
|
import initializeI18n from "./i18nFrontend.js";
|
|
17
|
-
import type { Ti18n } from "./
|
|
16
|
+
import type { Ti18n, TranslateOptions } from "./types.js";
|
|
18
17
|
|
|
19
18
|
const NAMESPACE = "translation";
|
|
20
19
|
|
|
@@ -24,7 +23,7 @@ export interface Translator {
|
|
|
24
23
|
* interpolation values and the `defaultValue` a widget falls back to when
|
|
25
24
|
* its key has not been added to the bundled catalogues yet.
|
|
26
25
|
*/
|
|
27
|
-
t(key: string, options?:
|
|
26
|
+
t(key: string, options?: TranslateOptions): string;
|
|
28
27
|
/**
|
|
29
28
|
* Whether the namespace for the active language has finished loading.
|
|
30
29
|
* Mirrors `ready` from the old react-i18next hook: widgets render an empty
|
|
@@ -40,9 +39,8 @@ export interface Translator {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
|
-
* Vanilla replacement for the `useTranslation` hook
|
|
44
|
-
*
|
|
45
|
-
* events that used to trigger a React re-render.
|
|
42
|
+
* Vanilla replacement for the `useTranslation` hook: the instance's `t` plus
|
|
43
|
+
* the three events that used to trigger a React re-render.
|
|
46
44
|
*/
|
|
47
45
|
export const createTranslator = (existing?: Ti18n): Translator => {
|
|
48
46
|
const i18n: Ti18n = existing ?? initializeI18n();
|
|
@@ -51,7 +49,8 @@ export const createTranslator = (existing?: Ti18n): Translator => {
|
|
|
51
49
|
i18n.isInitialized && i18n.hasLoadedNamespace(NAMESPACE);
|
|
52
50
|
|
|
53
51
|
return {
|
|
54
|
-
t: (key: string, options?:
|
|
52
|
+
t: (key: string, options?: TranslateOptions): string =>
|
|
53
|
+
i18n.t(key, options),
|
|
55
54
|
isReady,
|
|
56
55
|
i18n,
|
|
57
56
|
subscribe: (listener: () => void): (() => void) => {
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* Interpolation values, plus the `defaultValue` a caller falls back to when its
|
|
17
|
+
* key is not in the catalogue. Values are substituted into `{{name}}`
|
|
18
|
+
* placeholders.
|
|
19
|
+
*/
|
|
20
|
+
export interface TranslateOptions {
|
|
21
|
+
defaultValue?: string;
|
|
22
|
+
[value: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type TranslateFn = (key: string, options?: TranslateOptions) => string;
|
|
26
|
+
|
|
27
|
+
export type I18nEvent = "initialized" | "loaded" | "languageChanged";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The slice of an i18n instance this repository uses. Both implementations
|
|
31
|
+
* satisfy it: the browser one in `i18nFrontend.ts`, and i18next itself on the
|
|
32
|
+
* server, where `i18next-http-middleware` needs the real thing.
|
|
33
|
+
*/
|
|
34
|
+
export interface Ti18n {
|
|
35
|
+
readonly language: string;
|
|
36
|
+
readonly isInitialized: boolean;
|
|
37
|
+
t: TranslateFn;
|
|
38
|
+
changeLanguage(language: string): Promise<unknown>;
|
|
39
|
+
hasLoadedNamespace(namespace: string): boolean;
|
|
40
|
+
on(event: I18nEvent, listener: () => void): void;
|
|
41
|
+
off(event: I18nEvent, listener: () => void): void;
|
|
42
|
+
}
|
package/tsconfig.cjs.json
CHANGED