@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.
Files changed (158) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +15 -13
  2. package/.turbo/turbo-build$colon$tsc.log +8 -5
  3. package/.turbo/turbo-build.log +17 -15
  4. package/CHANGELOG.md +74 -0
  5. package/dist/cjs/i18SharedOptions.cjs +2 -2
  6. package/dist/cjs/i18nBackend.cjs +1 -1
  7. package/dist/cjs/i18nFrontend.cjs +167 -53
  8. package/dist/cjs/i18nMiddleware.cjs +5 -2
  9. package/dist/cjs/index.cjs +5 -4
  10. package/dist/cjs/locales/en/translation.cjs +1 -0
  11. package/dist/cjs/translationKey.cjs +2 -3
  12. package/dist/cjs/translations.cjs +4 -5
  13. package/dist/cjs/translator.cjs +29 -0
  14. package/dist/i18SharedOptions.d.ts +2 -2
  15. package/dist/i18SharedOptions.js +3 -3
  16. package/dist/i18SharedOptions.js.map +1 -1
  17. package/dist/i18nBackend.js +1 -1
  18. package/dist/i18nFrontend.d.ts +2 -2
  19. package/dist/i18nFrontend.d.ts.map +1 -1
  20. package/dist/i18nFrontend.js +167 -47
  21. package/dist/i18nFrontend.js.map +1 -1
  22. package/dist/i18nMiddleware.d.ts.map +1 -1
  23. package/dist/i18nMiddleware.js +3 -1
  24. package/dist/i18nMiddleware.js.map +1 -1
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -4
  28. package/dist/index.js.map +1 -1
  29. package/dist/loadI18next.d.ts +2 -3
  30. package/dist/loadI18next.d.ts.map +1 -1
  31. package/dist/loadI18next.js.map +1 -1
  32. package/dist/locales/ar/translation.json +1 -0
  33. package/dist/locales/az/translation.json +1 -0
  34. package/dist/locales/cs/translation.json +1 -0
  35. package/dist/locales/de/translation.json +1 -0
  36. package/dist/locales/el/translation.json +1 -0
  37. package/dist/locales/en/translation.js +1 -0
  38. package/dist/locales/en/translation.json +1 -0
  39. package/dist/locales/es/translation.json +1 -0
  40. package/dist/locales/fi/translation.json +1 -0
  41. package/dist/locales/fr/translation.json +1 -0
  42. package/dist/locales/hi/translation.json +1 -0
  43. package/dist/locales/hu/translation.json +1 -0
  44. package/dist/locales/id/translation.json +1 -0
  45. package/dist/locales/it/translation.json +1 -0
  46. package/dist/locales/ja/translation.json +1 -0
  47. package/dist/locales/jv/translation.json +1 -0
  48. package/dist/locales/ko/translation.json +1 -0
  49. package/dist/locales/ml/translation.json +1 -0
  50. package/dist/locales/ms/translation.json +1 -0
  51. package/dist/locales/nl/translation.json +1 -0
  52. package/dist/locales/no/translation.json +1 -0
  53. package/dist/locales/pl/translation.json +1 -0
  54. package/dist/locales/pt/translation.json +1 -0
  55. package/dist/locales/pt-BR/translation.json +1 -0
  56. package/dist/locales/ro/translation.json +1 -0
  57. package/dist/locales/ru/translation.json +1 -0
  58. package/dist/locales/sr/translation.json +1 -0
  59. package/dist/locales/sv/translation.json +1 -0
  60. package/dist/locales/th/translation.json +1 -0
  61. package/dist/locales/tr/translation.json +1 -0
  62. package/dist/locales/uk/translation.json +1 -0
  63. package/dist/locales/vi/translation.json +1 -0
  64. package/dist/locales/zh-CN/translation.json +1 -0
  65. package/dist/tests/i18nFrontend.unit.test.js +218 -173
  66. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  67. package/dist/tests/i18nMiddleware.unit.test.js +11 -2
  68. package/dist/tests/i18nMiddleware.unit.test.js.map +1 -1
  69. package/dist/tests/loadI18next.unit.test.js.map +1 -1
  70. package/dist/tests/locale.test-d.js +27 -20
  71. package/dist/tests/locale.test-d.js.map +1 -1
  72. package/dist/tests/translationKey.unit.test.js +11 -33
  73. package/dist/tests/translationKey.unit.test.js.map +1 -1
  74. package/dist/tests/translations.unit.test.js +12 -22
  75. package/dist/tests/translations.unit.test.js.map +1 -1
  76. package/dist/tests/translator.unit.test.d.ts +2 -0
  77. package/dist/tests/translator.unit.test.d.ts.map +1 -0
  78. package/dist/tests/translator.unit.test.js +134 -0
  79. package/dist/tests/translator.unit.test.js.map +1 -0
  80. package/dist/translationKey.d.ts +2 -3
  81. package/dist/translationKey.d.ts.map +1 -1
  82. package/dist/translationKey.js +2 -3
  83. package/dist/translationKey.js.map +1 -1
  84. package/dist/translations.d.ts +3 -2
  85. package/dist/translations.d.ts.map +1 -1
  86. package/dist/translations.js +3 -3
  87. package/dist/translations.js.map +1 -1
  88. package/dist/translator.d.ts +9 -0
  89. package/dist/translator.d.ts.map +1 -0
  90. package/dist/translator.js +29 -0
  91. package/dist/translator.js.map +1 -0
  92. package/dist/types.d.ts +16 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +2 -0
  95. package/dist/types.js.map +1 -0
  96. package/package.json +4 -9
  97. package/src/i18SharedOptions.ts +3 -3
  98. package/src/i18nFrontend.ts +240 -78
  99. package/src/i18nMiddleware.ts +6 -1
  100. package/src/index.ts +11 -4
  101. package/src/loadI18next.ts +8 -9
  102. package/src/locales/ar/translation.json +1 -0
  103. package/src/locales/az/translation.json +1 -0
  104. package/src/locales/cs/translation.json +1 -0
  105. package/src/locales/de/translation.json +1 -0
  106. package/src/locales/el/translation.json +1 -0
  107. package/src/locales/en/translation.json +1 -0
  108. package/src/locales/es/translation.json +1 -0
  109. package/src/locales/fi/translation.json +1 -0
  110. package/src/locales/fr/translation.json +1 -0
  111. package/src/locales/hi/translation.json +1 -0
  112. package/src/locales/hu/translation.json +1 -0
  113. package/src/locales/id/translation.json +1 -0
  114. package/src/locales/it/translation.json +1 -0
  115. package/src/locales/ja/translation.json +1 -0
  116. package/src/locales/jv/translation.json +1 -0
  117. package/src/locales/ko/translation.json +1 -0
  118. package/src/locales/ml/translation.json +1 -0
  119. package/src/locales/ms/translation.json +1 -0
  120. package/src/locales/nl/translation.json +1 -0
  121. package/src/locales/no/translation.json +1 -0
  122. package/src/locales/pl/translation.json +1 -0
  123. package/src/locales/pt/translation.json +1 -0
  124. package/src/locales/pt-BR/translation.json +1 -0
  125. package/src/locales/ro/translation.json +1 -0
  126. package/src/locales/ru/translation.json +1 -0
  127. package/src/locales/sr/translation.json +1 -0
  128. package/src/locales/sv/translation.json +1 -0
  129. package/src/locales/th/translation.json +1 -0
  130. package/src/locales/tr/translation.json +1 -0
  131. package/src/locales/uk/translation.json +1 -0
  132. package/src/locales/vi/translation.json +1 -0
  133. package/src/locales/zh-CN/translation.json +1 -0
  134. package/src/tests/i18nFrontend.unit.test.ts +310 -219
  135. package/src/tests/i18nMiddleware.unit.test.ts +21 -2
  136. package/src/tests/loadI18next.unit.test.ts +11 -11
  137. package/src/tests/locale.test-d.ts +36 -32
  138. package/src/tests/translationKey.unit.test.ts +12 -38
  139. package/src/tests/translations.unit.test.ts +18 -31
  140. package/src/tests/translator.unit.test.ts +213 -0
  141. package/src/translationKey.ts +10 -9
  142. package/src/translations.ts +6 -5
  143. package/src/translator.ts +68 -0
  144. package/src/types.ts +42 -0
  145. package/tsconfig.cjs.json +3 -0
  146. package/tsconfig.json +3 -0
  147. package/tsconfig.tsbuildinfo +1 -1
  148. package/dist/cjs/useTranslation.cjs +0 -12
  149. package/dist/tests/useTranslation.unit.test.d.ts +0 -2
  150. package/dist/tests/useTranslation.unit.test.d.ts.map +0 -1
  151. package/dist/tests/useTranslation.unit.test.js +0 -81
  152. package/dist/tests/useTranslation.unit.test.js.map +0 -1
  153. package/dist/useTranslation.d.ts +0 -7
  154. package/dist/useTranslation.d.ts.map +0 -1
  155. package/dist/useTranslation.js +0 -11
  156. package/dist/useTranslation.js.map +0 -1
  157. package/src/tests/useTranslation.unit.test.ts +0 -134
  158. 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 loaded i18next instance", async () => {
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: they
20
- // pull in i18next, its plugins and the filesystem, none of which this module's
21
- // own logic depends on.
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 the i18next instance loadI18next actually touches. */
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): i18n => fake as unknown as i18n;
30
+ const asI18n = (fake: FakeI18n): Ti18n => fake as unknown as Ti18n;
31
31
 
32
32
  type InitializeI18n = (
33
- callback?: (instance: i18n) => void,
33
+ callback?: (instance: Ti18n) => void,
34
34
  lng?: string,
35
- ) => i18n;
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<i18n>
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: i18n) => void) | undefined;
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: i18n) => void) | undefined;
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
- LanguageSchema,
21
+ type Language,
22
+ LanguageCodes,
22
23
  Languages,
23
24
  type Ti18n,
24
25
  type TranslationKey,
25
- TranslationKeysSchema,
26
+ createTranslator,
26
27
  i18nMiddleware,
27
28
  isClientSide,
29
+ isLanguage,
28
30
  loadI18next,
29
- useTranslation,
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 LanguageSchema derive its enum. Widening this
35
- // to Record<string, string> would silently turn the schema into a
36
- // runtime-only constraint.
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("LanguageSchema", () => {
53
- it("parses to a string", () => {
54
- // The enum is built from a runtime `Object.values` cast, so the parsed
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("exposes .enum as a lookup of codes", () => {
61
- expectTypeOf(LanguageSchema.enum).toExtend<Record<string, string>>();
59
+ it("accepts any string at the boundary — input is untrusted markup", () => {
60
+ expectTypeOf(isLanguage).toBeCallableWith("anything");
62
61
  });
63
62
 
64
- it("accepts unknown input at the parse boundary", () => {
65
- expectTypeOf(LanguageSchema.parse).toBeCallableWith("anything");
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 inferred from the schema", () => {
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 of options", () => {
83
- // A non-empty tuple, which is what z.enum requires and what the mongoose
84
- // schemas in types-database spread into their `enum` fields.
85
- expectTypeOf(TranslationKeysSchema.options).toExtend<readonly string[]>();
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("useTranslation", () => {
144
- it("is callable with no options", () => {
145
- expectTypeOf(useTranslation).toBeCallableWith();
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 a t function", () => {
149
- expectTypeOf(useTranslation).returns.toHaveProperty("t");
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 unknown option", () => {
153
- // @ts-expect-error notAnOption is not a UseTranslationOptions field
154
- useTranslation({ notAnOption: 1 });
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 TranslationKeysSchema an empty enum.
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("TranslationKeysSchema", () => {
115
+ describe("translationKeys", () => {
116
116
  const keys = getLeafFieldPath(translationEn as TranslationNode);
117
117
 
118
- test("is non-empty — an empty z.enum would throw at module load", () => {
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("accepts every key derived from the English translation", () => {
123
- for (const key of keys) {
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("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", () => {
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(() => 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);
130
+ expect(translationKeys).not.toContain(section);
152
131
  });
153
132
 
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
- }
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("exposes exactly the derived keys as enum options", () => {
164
- expect(new Set(TranslationKeysSchema.options)).toEqual(new Set(keys));
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 { LanguageSchema, Languages } from "../translations.js";
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 shrink LanguageSchema's enum and make
38
- // one of the language names unreachable, while the locales-folder count
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("LanguageSchema", () => {
64
+ describe("isLanguage", () => {
66
65
  test("accepts every declared code", () => {
67
66
  for (const code of codes) {
68
- expect(LanguageSchema.parse(code)).toBe(code);
67
+ expect(isLanguage(code)).toBe(true);
69
68
  }
70
69
  });
71
70
 
72
- test("exposes each code on .enum for use as a constant", () => {
73
- expect(LanguageSchema.enum.en).toBe("en");
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(() => LanguageSchema.parse("xx")).toThrow();
80
+ expect(isLanguage("xx")).toBe(false);
78
81
  });
79
82
 
80
83
  test("rejects the empty string", () => {
81
- expect(() => LanguageSchema.parse("")).toThrow();
84
+ expect(isLanguage("")).toBe(false);
82
85
  });
83
86
 
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
+ // 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(() => LanguageSchema.parse("en-GB")).toThrow();
91
+ expect(isLanguage("en-GB")).toBe(false);
89
92
  });
90
93
 
91
94
  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));
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
+ });
@@ -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 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.
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 TranslationKeysSchema = z.enum(
60
- getLeafFieldPath(translationEn) as [string, ...string[]],
61
- );
58
+ export const translationKeys: string[] = getLeafFieldPath(translationEn);
62
59
 
63
- export type TranslationKey = z.infer<typeof TranslationKeysSchema>;
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;