@prosopo/locale 3.2.9 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +10 -9
  2. package/.turbo/turbo-build$colon$tsc.log +5 -5
  3. package/.turbo/turbo-build.log +17 -16
  4. package/CHANGELOG.md +24 -0
  5. package/dist/cjs/i18SharedOptions.cjs +1 -1
  6. package/dist/cjs/i18nBackend.cjs +4 -1
  7. package/dist/cjs/i18nFrontend.cjs +4 -1
  8. package/dist/cjs/loadI18next.cjs +41 -14
  9. package/dist/cjs/process.cjs +17 -0
  10. package/dist/cjs/translationKey.cjs +2 -0
  11. package/dist/i18SharedOptions.js +2 -1
  12. package/dist/i18SharedOptions.js.map +1 -1
  13. package/dist/i18nBackend.d.ts.map +1 -1
  14. package/dist/i18nBackend.js +4 -1
  15. package/dist/i18nBackend.js.map +1 -1
  16. package/dist/i18nFrontend.d.ts.map +1 -1
  17. package/dist/i18nFrontend.js +4 -1
  18. package/dist/i18nFrontend.js.map +1 -1
  19. package/dist/loadI18next.d.ts +1 -0
  20. package/dist/loadI18next.d.ts.map +1 -1
  21. package/dist/loadI18next.js +41 -15
  22. package/dist/loadI18next.js.map +1 -1
  23. package/dist/process.d.ts +2 -0
  24. package/dist/process.d.ts.map +1 -0
  25. package/dist/process.js +17 -0
  26. package/dist/process.js.map +1 -0
  27. package/dist/tests/i18SharedOptions.unit.test.d.ts +2 -0
  28. package/dist/tests/i18SharedOptions.unit.test.d.ts.map +1 -0
  29. package/dist/tests/i18SharedOptions.unit.test.js +91 -0
  30. package/dist/tests/i18SharedOptions.unit.test.js.map +1 -0
  31. package/dist/tests/i18nBackend.unit.test.d.ts +2 -0
  32. package/dist/tests/i18nBackend.unit.test.d.ts.map +1 -0
  33. package/dist/tests/i18nBackend.unit.test.js +153 -0
  34. package/dist/tests/i18nBackend.unit.test.js.map +1 -0
  35. package/dist/tests/i18nFrontend.unit.test.d.ts +2 -0
  36. package/dist/tests/i18nFrontend.unit.test.d.ts.map +1 -0
  37. package/dist/tests/i18nFrontend.unit.test.js +199 -0
  38. package/dist/tests/i18nFrontend.unit.test.js.map +1 -0
  39. package/dist/tests/i18nMiddleware.unit.test.d.ts +2 -0
  40. package/dist/tests/i18nMiddleware.unit.test.d.ts.map +1 -0
  41. package/dist/tests/i18nMiddleware.unit.test.js +80 -0
  42. package/dist/tests/i18nMiddleware.unit.test.js.map +1 -0
  43. package/dist/tests/loadI18next.unit.test.d.ts +2 -0
  44. package/dist/tests/loadI18next.unit.test.d.ts.map +1 -0
  45. package/dist/tests/loadI18next.unit.test.js +270 -0
  46. package/dist/tests/loadI18next.unit.test.js.map +1 -0
  47. package/dist/tests/locale.test-d.d.ts +2 -0
  48. package/dist/tests/locale.test-d.d.ts.map +1 -0
  49. package/dist/tests/locale.test-d.js +88 -0
  50. package/dist/tests/locale.test-d.js.map +1 -0
  51. package/dist/tests/process.unit.test.d.ts +2 -0
  52. package/dist/tests/process.unit.test.d.ts.map +1 -0
  53. package/dist/tests/process.unit.test.js +14 -0
  54. package/dist/tests/process.unit.test.js.map +1 -0
  55. package/dist/tests/translationKey.unit.test.d.ts +2 -0
  56. package/dist/tests/translationKey.unit.test.d.ts.map +1 -0
  57. package/dist/tests/translationKey.unit.test.js +108 -0
  58. package/dist/tests/translationKey.unit.test.js.map +1 -0
  59. package/dist/tests/translations.unit.test.d.ts +2 -0
  60. package/dist/tests/translations.unit.test.d.ts.map +1 -0
  61. package/dist/tests/translations.unit.test.js +74 -0
  62. package/dist/tests/translations.unit.test.js.map +1 -0
  63. package/dist/tests/useTranslation.unit.test.d.ts +2 -0
  64. package/dist/tests/useTranslation.unit.test.d.ts.map +1 -0
  65. package/dist/tests/useTranslation.unit.test.js +81 -0
  66. package/dist/tests/useTranslation.unit.test.js.map +1 -0
  67. package/dist/tests/util.unit.test.d.ts +2 -0
  68. package/dist/tests/util.unit.test.d.ts.map +1 -0
  69. package/dist/tests/util.unit.test.js +66 -0
  70. package/dist/tests/util.unit.test.js.map +1 -0
  71. package/dist/translationKey.d.ts +4 -0
  72. package/dist/translationKey.d.ts.map +1 -1
  73. package/dist/translationKey.js +2 -1
  74. package/dist/translationKey.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/i18SharedOptions.ts +4 -4
  77. package/src/i18nBackend.ts +16 -0
  78. package/src/i18nFrontend.ts +16 -0
  79. package/src/loadI18next.ts +83 -15
  80. package/src/process.ts +29 -0
  81. package/src/tests/i18SharedOptions.unit.test.ts +139 -0
  82. package/src/tests/i18nBackend.unit.test.ts +241 -0
  83. package/src/tests/i18nFrontend.unit.test.ts +302 -0
  84. package/src/tests/i18nMiddleware.unit.test.ts +140 -0
  85. package/src/tests/loadI18next.unit.test.ts +443 -0
  86. package/src/tests/locale.test-d.ts +156 -0
  87. package/src/tests/process.unit.test.ts +33 -0
  88. package/src/tests/translationKey.unit.test.ts +166 -0
  89. package/src/tests/translations.unit.test.ts +110 -0
  90. package/src/tests/useTranslation.unit.test.ts +134 -0
  91. package/src/tests/util.unit.test.ts +102 -0
  92. package/src/translationKey.ts +18 -3
  93. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,166 @@
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 { describe, expect, test } from "vitest";
16
+ import translationEn from "../locales/en/translation.json" with {
17
+ type: "json",
18
+ };
19
+ import {
20
+ TranslationKeysSchema,
21
+ type TranslationNode,
22
+ getLeafFieldPath,
23
+ } from "../translationKey.js";
24
+
25
+ describe("getLeafFieldPath", () => {
26
+ test("returns no paths for a bare string, which is a leaf and not a container", () => {
27
+ expect(getLeafFieldPath("a string")).toEqual([]);
28
+ });
29
+
30
+ test("returns no paths for the empty string", () => {
31
+ expect(getLeafFieldPath("")).toEqual([]);
32
+ });
33
+
34
+ // The string is the leaf, so the path ends at its key. This used to yield
35
+ // [] — the recursion returned nothing for a string and the parent mapped
36
+ // over that empty list — which made the whole function return [] for every
37
+ // input and left TranslationKeysSchema an empty enum.
38
+ test("emits a top-level string value under its own key", () => {
39
+ expect(getLeafFieldPath({ topLevel: "value" })).toEqual(["topLevel"]);
40
+ });
41
+
42
+ test("emits section.key for a one-level-deep section", () => {
43
+ expect(getLeafFieldPath({ section: { key: "value" } })).toEqual([
44
+ "section.key",
45
+ ]);
46
+ });
47
+
48
+ test("emits every key of a section, in insertion order", () => {
49
+ expect(
50
+ getLeafFieldPath({ section: { first: "1", second: "2", third: "3" } }),
51
+ ).toEqual(["section.first", "section.second", "section.third"]);
52
+ });
53
+
54
+ test("recurses to arbitrary depth, joining each level with a dot", () => {
55
+ expect(getLeafFieldPath({ a: { b: { c: { d: "leaf" } } } })).toEqual([
56
+ "a.b.c.d",
57
+ ]);
58
+ });
59
+
60
+ test("handles sibling branches of differing depth", () => {
61
+ expect(
62
+ getLeafFieldPath({
63
+ shallow: { key: "v" },
64
+ deep: { nested: { key: "v" } },
65
+ }),
66
+ ).toEqual(["shallow.key", "deep.nested.key"]);
67
+ });
68
+
69
+ test("returns no paths for an empty object", () => {
70
+ expect(getLeafFieldPath({})).toEqual([]);
71
+ });
72
+
73
+ // Same silent-drop hazard as the top-level string: an empty section
74
+ // contributes nothing rather than erroring, so a translation file that
75
+ // loses all of a section's contents still parses.
76
+ test("drops an empty nested section rather than erroring", () => {
77
+ expect(getLeafFieldPath({ section: {} })).toEqual([]);
78
+ });
79
+
80
+ test("keeps populated siblings when one section is empty", () => {
81
+ expect(getLeafFieldPath({ empty: {}, full: { key: "v" } })).toEqual([
82
+ "full.key",
83
+ ]);
84
+ });
85
+
86
+ // Object.keys never yields a key whose value is undefined by omission, so
87
+ // this guard is only reachable via an explicitly-assigned undefined. It
88
+ // still has to hold: a JSON file cannot express it, but a caller passing a
89
+ // hand-built object can, and a silently-skipped key would corrupt the
90
+ // schema.
91
+ test("throws on an explicitly undefined value, naming the offending key", () => {
92
+ const node = {
93
+ section: { broken: undefined },
94
+ } as unknown as TranslationNode;
95
+ expect(() => getLeafFieldPath(node)).toThrow(
96
+ "Undefined value for key broken",
97
+ );
98
+ });
99
+
100
+ test("throws before emitting any of the sibling keys of the broken one", () => {
101
+ const node = {
102
+ section: { ok: "v", broken: undefined },
103
+ } as unknown as TranslationNode;
104
+ expect(() => getLeafFieldPath(node)).toThrow(/Undefined value/);
105
+ });
106
+
107
+ test("does not mutate the input", () => {
108
+ const node: TranslationNode = { section: { key: "value" } };
109
+ const snapshot = JSON.stringify(node);
110
+ getLeafFieldPath(node);
111
+ expect(JSON.stringify(node)).toBe(snapshot);
112
+ });
113
+ });
114
+
115
+ describe("TranslationKeysSchema", () => {
116
+ const keys = getLeafFieldPath(translationEn as TranslationNode);
117
+
118
+ test("is non-empty — an empty z.enum would throw at module load", () => {
119
+ expect(keys.length).toBeGreaterThan(0);
120
+ });
121
+
122
+ test("accepts every key derived from the English translation", () => {
123
+ for (const key of keys) {
124
+ expect(TranslationKeysSchema.parse(key)).toBe(key);
125
+ }
126
+ });
127
+
128
+ test("rejects a key that is not in the English translation", () => {
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", () => {
137
+ const [firstKey] = keys;
138
+ expect(firstKey).toBeDefined();
139
+ const section = String(firstKey).split(".")[0];
140
+ expect(() => TranslationKeysSchema.parse(section)).toThrow();
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);
152
+ });
153
+
154
+ test("is case sensitive", () => {
155
+ const [firstKey] = keys;
156
+ expect(firstKey).toBeDefined();
157
+ const upper = String(firstKey).toUpperCase();
158
+ if (upper !== firstKey) {
159
+ expect(() => TranslationKeysSchema.parse(upper)).toThrow();
160
+ }
161
+ });
162
+
163
+ test("exposes exactly the derived keys as enum options", () => {
164
+ expect(new Set(TranslationKeysSchema.options)).toEqual(new Set(keys));
165
+ });
166
+ });
@@ -0,0 +1,110 @@
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, test } from "vitest";
18
+ import { LanguageSchema, Languages } from "../translations.js";
19
+
20
+ const codes: string[] = Object.values(Languages);
21
+
22
+ describe("Languages", () => {
23
+ test("is non-empty", () => {
24
+ expect(codes.length).toBeGreaterThan(0);
25
+ });
26
+
27
+ test("maps English language names to BCP 47 codes", () => {
28
+ expect(Languages.english).toBe("en");
29
+ expect(Languages.chinese).toBe("zh-CN");
30
+ expect(Languages.portugeseBrazil).toBe("pt-BR");
31
+ });
32
+
33
+ test("includes the fallback language", () => {
34
+ expect(codes).toContain("en");
35
+ });
36
+
37
+ // A duplicated code would silently shrink LanguageSchema's enum and make
38
+ // one of the language names unreachable, while the locales-folder count
39
+ // test would still pass.
40
+ test("has no duplicate codes", () => {
41
+ expect(new Set(codes).size).toBe(codes.length);
42
+ });
43
+
44
+ test("every code is a well-formed, lowercase-primary language tag", () => {
45
+ for (const code of codes) {
46
+ expect(code).toMatch(/^[a-z]{2}(-[A-Z]{2})?$/);
47
+ }
48
+ });
49
+
50
+ test("every code has a locales directory containing translation.json", () => {
51
+ for (const code of codes) {
52
+ const file = path.resolve(`./src/locales/${code}/translation.json`);
53
+ expect(fs.existsSync(file), `missing ${code}`).toBe(true);
54
+ }
55
+ });
56
+
57
+ test("every locales directory is claimed by a code — no orphans", () => {
58
+ const dirs = fs.readdirSync(path.resolve("./src/locales"));
59
+ for (const dir of dirs) {
60
+ expect(codes, `orphan locale directory ${dir}`).toContain(dir);
61
+ }
62
+ });
63
+ });
64
+
65
+ describe("LanguageSchema", () => {
66
+ test("accepts every declared code", () => {
67
+ for (const code of codes) {
68
+ expect(LanguageSchema.parse(code)).toBe(code);
69
+ }
70
+ });
71
+
72
+ test("exposes each code on .enum for use as a constant", () => {
73
+ expect(LanguageSchema.enum.en).toBe("en");
74
+ });
75
+
76
+ test("rejects an undeclared language code", () => {
77
+ expect(() => LanguageSchema.parse("xx")).toThrow();
78
+ });
79
+
80
+ test("rejects the empty string", () => {
81
+ expect(() => LanguageSchema.parse("")).toThrow();
82
+ });
83
+
84
+ // i18next treats `en-GB` as a region of `en`, but the schema is a plain
85
+ // enum with `nonExplicitSupportedLngs: false` alongside it, so a regional
86
+ // variant of a supported language is *not* itself valid input.
87
+ test("rejects a regional variant of a supported language", () => {
88
+ expect(() => LanguageSchema.parse("en-GB")).toThrow();
89
+ });
90
+
91
+ test("is case sensitive — `EN` is not `en`", () => {
92
+ expect(() => LanguageSchema.parse("EN")).toThrow();
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));
109
+ });
110
+ });
@@ -0,0 +1,134 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import type { i18n } from "i18next";
16
+ import type { UseTranslationOptions } from "react-i18next";
17
+ import { afterEach, describe, expect, test, vi } from "vitest";
18
+
19
+ // The hook is a thin wrapper: it forces the package's own i18next instance in
20
+ // rather than letting react-i18next pick up whichever instance a host app's
21
+ // provider happens to expose. React itself is never rendered here, so both
22
+ // react-i18next and the frontend initialiser are mocked.
23
+
24
+ type DefaultCall = [string, { i18n: i18n } & Record<string, unknown>];
25
+
26
+ const asI18n = (value: object): i18n => value as unknown as i18n;
27
+
28
+ const load = async (): Promise<{
29
+ useTranslation: (options?: UseTranslationOptions<"translation">) => unknown;
30
+ instance: i18n;
31
+ calls: DefaultCall[];
32
+ initCount: () => number;
33
+ }> => {
34
+ vi.resetModules();
35
+
36
+ const instance = asI18n({ name: "package instance" });
37
+ const calls: DefaultCall[] = [];
38
+ let initCalls = 0;
39
+
40
+ vi.doMock("../i18nFrontend.js", () => ({
41
+ default: (): i18n => {
42
+ initCalls += 1;
43
+ return instance;
44
+ },
45
+ }));
46
+ vi.doMock("react-i18next", () => ({
47
+ useTranslation: (
48
+ namespace: string,
49
+ options: { i18n: i18n } & Record<string, unknown>,
50
+ ): unknown => {
51
+ calls.push([namespace, options]);
52
+ return { t: (key: string): string => key, i18n: options.i18n };
53
+ },
54
+ }));
55
+
56
+ const imported = await import("../useTranslation.js");
57
+ return {
58
+ useTranslation: imported.default,
59
+ instance,
60
+ calls,
61
+ initCount: (): number => initCalls,
62
+ };
63
+ };
64
+
65
+ afterEach(() => {
66
+ vi.resetModules();
67
+ vi.doUnmock("../i18nFrontend.js");
68
+ vi.doUnmock("react-i18next");
69
+ });
70
+
71
+ describe("useTranslation", () => {
72
+ test("always asks for the translation namespace", async () => {
73
+ const { useTranslation, calls } = await load();
74
+ useTranslation();
75
+ expect(calls[0]?.[0]).toBe("translation");
76
+ });
77
+
78
+ test("injects the package's own i18next instance", async () => {
79
+ // Without this, a host application that mounts its own I18nextProvider
80
+ // would capture the widget's translations and render its keys instead.
81
+ const { useTranslation, calls, instance } = await load();
82
+ useTranslation();
83
+ expect(calls[0]?.[1].i18n).toBe(instance);
84
+ });
85
+
86
+ test("initialises the frontend instance on use", async () => {
87
+ const { useTranslation, initCount } = await load();
88
+ useTranslation();
89
+ expect(initCount()).toBe(1);
90
+ });
91
+
92
+ test("works with no options at all", async () => {
93
+ const { useTranslation, calls } = await load();
94
+ useTranslation();
95
+ expect(calls[0]?.[1]).toEqual({ i18n: expect.anything() });
96
+ });
97
+
98
+ test("passes caller options through", async () => {
99
+ const { useTranslation, calls } = await load();
100
+ useTranslation({ nsMode: "fallback", useSuspense: false });
101
+ expect(calls[0]?.[1]).toMatchObject({
102
+ nsMode: "fallback",
103
+ useSuspense: false,
104
+ });
105
+ });
106
+
107
+ test("lets the caller override the instance, since options spread last", async () => {
108
+ // Worth pinning: the spread order means a caller-supplied i18n wins.
109
+ // That is useful for tests and storybooks, and dangerous by accident,
110
+ // so it should not change silently.
111
+ const { useTranslation, calls, instance } = await load();
112
+ const other = asI18n({ name: "caller instance" });
113
+
114
+ useTranslation({ i18n: other } as UseTranslationOptions<"translation">);
115
+
116
+ expect(calls[0]?.[1].i18n).toBe(other);
117
+ expect(calls[0]?.[1].i18n).not.toBe(instance);
118
+ });
119
+
120
+ test("returns whatever react-i18next returns", async () => {
121
+ const { useTranslation } = await load();
122
+ const result = useTranslation() as { t: (key: string) => string };
123
+ expect(result.t("WIDGET.SUBMIT")).toBe("WIDGET.SUBMIT");
124
+ });
125
+
126
+ test("re-initialises on every call, matching hook semantics", async () => {
127
+ // initializeI18n is idempotent, so calling it per render is safe; the
128
+ // test exists so that stops being true loudly rather than quietly.
129
+ const { useTranslation, initCount } = await load();
130
+ useTranslation();
131
+ useTranslation();
132
+ expect(initCount()).toBe(2);
133
+ });
134
+ });
@@ -0,0 +1,102 @@
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, describe, expect, test } from "vitest";
16
+ import { isClientSide, isServerSide } from "../util.js";
17
+
18
+ // The detection reads `window` off the global object, so each test installs a
19
+ // fake and the teardown removes it. Reflect.deleteProperty rather than
20
+ // `delete globalThis.window` keeps TypeScript happy about an optional global.
21
+ type FakeWindow = {
22
+ document?: { createElement?: unknown };
23
+ };
24
+
25
+ const setWindow = (value: FakeWindow | undefined): void => {
26
+ if (value === undefined) {
27
+ Reflect.deleteProperty(globalThis, "window");
28
+ return;
29
+ }
30
+ Object.defineProperty(globalThis, "window", {
31
+ value,
32
+ configurable: true,
33
+ writable: true,
34
+ });
35
+ };
36
+
37
+ afterEach(() => {
38
+ setWindow(undefined);
39
+ });
40
+
41
+ describe("isClientSide", () => {
42
+ test("is false under plain node, where there is no window at all", () => {
43
+ expect(isClientSide()).toBe(false);
44
+ });
45
+
46
+ test("is true when window exposes document.createElement", () => {
47
+ setWindow({ document: { createElement: (): void => {} } });
48
+ expect(isClientSide()).toBe(true);
49
+ });
50
+
51
+ // The three guards are checked in order and each one has to be able to
52
+ // veto on its own, otherwise a half-built environment (jsdom teardown, a
53
+ // server-side-rendering shim that stubs `window` but not `document`) would
54
+ // be misread as a browser and the frontend i18n path would be selected on
55
+ // a server.
56
+ test("is false when window exists but has no document", () => {
57
+ setWindow({});
58
+ expect(isClientSide()).toBe(false);
59
+ });
60
+
61
+ test("is false when document exists but has no createElement", () => {
62
+ setWindow({ document: {} });
63
+ expect(isClientSide()).toBe(false);
64
+ });
65
+
66
+ test("is false when createElement is present but undefined", () => {
67
+ setWindow({ document: { createElement: undefined } });
68
+ expect(isClientSide()).toBe(false);
69
+ });
70
+
71
+ test("returns a real boolean, not the truthy function itself", () => {
72
+ const createElement = (): void => {};
73
+ setWindow({ document: { createElement } });
74
+ const result: boolean = isClientSide();
75
+ expect(result).toBe(true);
76
+ expect(typeof result).toBe("boolean");
77
+ });
78
+ });
79
+
80
+ describe("isServerSide", () => {
81
+ test("is true under plain node", () => {
82
+ expect(isServerSide()).toBe(true);
83
+ });
84
+
85
+ test("is false in a browser-like environment", () => {
86
+ setWindow({ document: { createElement: (): void => {} } });
87
+ expect(isServerSide()).toBe(false);
88
+ });
89
+
90
+ test("is always the exact negation of isClientSide", () => {
91
+ const environments: (FakeWindow | undefined)[] = [
92
+ undefined,
93
+ {},
94
+ { document: {} },
95
+ { document: { createElement: (): void => {} } },
96
+ ];
97
+ for (const environment of environments) {
98
+ setWindow(environment);
99
+ expect(isServerSide()).toBe(!isClientSide());
100
+ }
101
+ });
102
+ });
@@ -15,13 +15,16 @@
15
15
  import { z } from "zod";
16
16
  import translationEn from "./locales/en/translation.json" with { type: "json" };
17
17
 
18
- type Node =
18
+ export type TranslationNode =
19
19
  | {
20
- [key: string]: Node | string;
20
+ [key: string]: TranslationNode | string;
21
21
  }
22
22
  | string;
23
23
 
24
- function getLeafFieldPath(obj: Node): string[] {
24
+ // Exported so the traversal can be exercised directly against shapes the
25
+ // bundled English translation happens not to contain (deep nesting, empty
26
+ // objects, explicitly-undefined values).
27
+ export function getLeafFieldPath(obj: TranslationNode): string[] {
25
28
  if (typeof obj === "string") {
26
29
  return [];
27
30
  }
@@ -31,6 +34,18 @@ function getLeafFieldPath(obj: Node): string[] {
31
34
  if (value === undefined) {
32
35
  throw new Error(`Undefined value for key ${key}`);
33
36
  }
37
+
38
+ // A string value IS the leaf, so the path ends here. Recursing into it
39
+ // returns [] and the `children.map` below then contributes nothing,
40
+ // which is how this function used to return an empty array for every
41
+ // input — leaving TranslationKeysSchema an empty z.enum. That enum is
42
+ // spread into three mongoose `reason` fields (types-database
43
+ // provider.ts), and mongoose registers its enum validator even for an
44
+ // empty list, so every non-null reason failed validation.
45
+ if (typeof value === "string") {
46
+ return arr.concat(key);
47
+ }
48
+
34
49
  const children = getLeafFieldPath(value);
35
50
 
36
51
  return arr.concat(