@prosopo/locale 3.5.0 → 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 (76) 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 +16 -14
  4. package/CHANGELOG.md +47 -0
  5. package/dist/cjs/i18SharedOptions.cjs +2 -2
  6. package/dist/cjs/i18nBackend.cjs +1 -1
  7. package/dist/cjs/i18nFrontend.cjs +169 -51
  8. package/dist/cjs/i18nMiddleware.cjs +5 -2
  9. package/dist/cjs/index.cjs +3 -2
  10. package/dist/cjs/translationKey.cjs +2 -3
  11. package/dist/cjs/translations.cjs +4 -5
  12. package/dist/cjs/translator.cjs +2 -3
  13. package/dist/i18SharedOptions.d.ts +2 -2
  14. package/dist/i18SharedOptions.js +3 -3
  15. package/dist/i18SharedOptions.js.map +1 -1
  16. package/dist/i18nBackend.js +1 -1
  17. package/dist/i18nFrontend.d.ts +2 -2
  18. package/dist/i18nFrontend.d.ts.map +1 -1
  19. package/dist/i18nFrontend.js +169 -45
  20. package/dist/i18nFrontend.js.map +1 -1
  21. package/dist/i18nMiddleware.d.ts.map +1 -1
  22. package/dist/i18nMiddleware.js +3 -1
  23. package/dist/i18nMiddleware.js.map +1 -1
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -3
  27. package/dist/index.js.map +1 -1
  28. package/dist/loadI18next.d.ts +2 -3
  29. package/dist/loadI18next.d.ts.map +1 -1
  30. package/dist/loadI18next.js.map +1 -1
  31. package/dist/tests/i18nFrontend.unit.test.js +218 -170
  32. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  33. package/dist/tests/i18nMiddleware.unit.test.js +11 -2
  34. package/dist/tests/i18nMiddleware.unit.test.js.map +1 -1
  35. package/dist/tests/loadI18next.unit.test.js.map +1 -1
  36. package/dist/tests/locale.test-d.js +15 -11
  37. package/dist/tests/locale.test-d.js.map +1 -1
  38. package/dist/tests/translationKey.unit.test.js +11 -33
  39. package/dist/tests/translationKey.unit.test.js.map +1 -1
  40. package/dist/tests/translations.unit.test.js +12 -22
  41. package/dist/tests/translations.unit.test.js.map +1 -1
  42. package/dist/translationKey.d.ts +2 -3
  43. package/dist/translationKey.d.ts.map +1 -1
  44. package/dist/translationKey.js +2 -3
  45. package/dist/translationKey.js.map +1 -1
  46. package/dist/translations.d.ts +3 -2
  47. package/dist/translations.d.ts.map +1 -1
  48. package/dist/translations.js +3 -3
  49. package/dist/translations.js.map +1 -1
  50. package/dist/translator.d.ts +2 -3
  51. package/dist/translator.d.ts.map +1 -1
  52. package/dist/translator.js +2 -3
  53. package/dist/translator.js.map +1 -1
  54. package/dist/types.d.ts +16 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +2 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +3 -7
  59. package/src/i18SharedOptions.ts +3 -3
  60. package/src/i18nFrontend.ts +240 -73
  61. package/src/i18nMiddleware.ts +6 -1
  62. package/src/index.ts +9 -3
  63. package/src/loadI18next.ts +7 -8
  64. package/src/tests/i18nFrontend.unit.test.ts +310 -216
  65. package/src/tests/i18nMiddleware.unit.test.ts +21 -2
  66. package/src/tests/loadI18next.unit.test.ts +11 -11
  67. package/src/tests/locale.test-d.ts +24 -23
  68. package/src/tests/translationKey.unit.test.ts +12 -38
  69. package/src/tests/translations.unit.test.ts +18 -31
  70. package/src/translationKey.ts +10 -9
  71. package/src/translations.ts +6 -5
  72. package/src/translator.ts +6 -7
  73. package/src/types.ts +42 -0
  74. package/tsconfig.cjs.json +3 -0
  75. package/tsconfig.json +3 -0
  76. 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 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
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 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
 
@@ -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
  });
@@ -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;
@@ -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 const LanguageSchema = z.enum(
53
- Object.values(Languages) as [string, ...string[]],
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 "./loadI18next.js";
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?: TOptions): string;
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. i18next is already
44
- * framework-agnostic and event-driven; this just exposes its `t` plus the three
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?: TOptions): string => i18n.t(key, 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
@@ -13,6 +13,9 @@
13
13
  "references": [
14
14
  {
15
15
  "path": "../../dev/config/tsconfig.cjs.json"
16
+ },
17
+ {
18
+ "path": "../util/tsconfig.cjs.json"
16
19
  }
17
20
  ]
18
21
  }
package/tsconfig.json CHANGED
@@ -14,6 +14,9 @@
14
14
  "references": [
15
15
  {
16
16
  "path": "../../dev/config/tsconfig.json"
17
+ },
18
+ {
19
+ "path": "../util"
17
20
  }
18
21
  ]
19
22
  }