@prosopo/locale 3.6.1 → 3.6.2

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 (127) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +8 -6
  2. package/.turbo/turbo-build$colon$tsc.log +6 -6
  3. package/.turbo/turbo-build.log +11 -9
  4. package/CHANGELOG.md +24 -0
  5. package/dist/cjs/direction.cjs +19 -0
  6. package/dist/cjs/errorMessage.cjs +15 -0
  7. package/dist/cjs/index.cjs +5 -0
  8. package/dist/cjs/locales/en/translation.cjs +21 -4
  9. package/dist/cjs/translationKey.cjs +4 -0
  10. package/dist/direction.d.ts +3 -0
  11. package/dist/direction.d.ts.map +1 -0
  12. package/dist/direction.js +19 -0
  13. package/dist/direction.js.map +1 -0
  14. package/dist/errorMessage.d.ts +7 -0
  15. package/dist/errorMessage.d.ts.map +1 -0
  16. package/dist/errorMessage.js +15 -0
  17. package/dist/errorMessage.js.map +1 -0
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/locales/ar/translation.json +21 -4
  23. package/dist/locales/az/translation.json +21 -4
  24. package/dist/locales/cs/translation.json +21 -4
  25. package/dist/locales/de/translation.json +21 -4
  26. package/dist/locales/el/translation.json +21 -4
  27. package/dist/locales/en/translation.js +21 -4
  28. package/dist/locales/en/translation.json +21 -4
  29. package/dist/locales/es/translation.json +21 -4
  30. package/dist/locales/fi/translation.json +21 -4
  31. package/dist/locales/fr/translation.json +21 -4
  32. package/dist/locales/hi/translation.json +21 -4
  33. package/dist/locales/hu/translation.json +21 -4
  34. package/dist/locales/id/translation.json +21 -4
  35. package/dist/locales/it/translation.json +21 -4
  36. package/dist/locales/ja/translation.json +21 -4
  37. package/dist/locales/jv/translation.json +21 -4
  38. package/dist/locales/ko/translation.json +21 -4
  39. package/dist/locales/ml/translation.json +21 -4
  40. package/dist/locales/ms/translation.json +21 -4
  41. package/dist/locales/nl/translation.json +21 -4
  42. package/dist/locales/no/translation.json +21 -4
  43. package/dist/locales/pl/translation.json +21 -4
  44. package/dist/locales/pt/translation.json +21 -4
  45. package/dist/locales/pt-BR/translation.json +21 -4
  46. package/dist/locales/ro/translation.json +21 -4
  47. package/dist/locales/ru/translation.json +21 -4
  48. package/dist/locales/sr/translation.json +21 -4
  49. package/dist/locales/sv/translation.json +21 -4
  50. package/dist/locales/th/translation.json +21 -4
  51. package/dist/locales/tr/translation.json +21 -4
  52. package/dist/locales/uk/translation.json +21 -4
  53. package/dist/locales/vi/translation.json +21 -4
  54. package/dist/locales/zh-CN/translation.json +21 -4
  55. package/dist/tests/direction.unit.test.d.ts +2 -0
  56. package/dist/tests/direction.unit.test.d.ts.map +1 -0
  57. package/dist/tests/direction.unit.test.js +31 -0
  58. package/dist/tests/direction.unit.test.js.map +1 -0
  59. package/dist/tests/errorMessage.unit.test.d.ts +2 -0
  60. package/dist/tests/errorMessage.unit.test.d.ts.map +1 -0
  61. package/dist/tests/errorMessage.unit.test.js +49 -0
  62. package/dist/tests/errorMessage.unit.test.js.map +1 -0
  63. package/dist/tests/i18nFrontend.unit.test.js +22 -11
  64. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  65. package/dist/tests/locale.test-d.js +18 -3
  66. package/dist/tests/locale.test-d.js.map +1 -1
  67. package/dist/tests/thrownErrorKeys.unit.test.d.ts +2 -0
  68. package/dist/tests/thrownErrorKeys.unit.test.d.ts.map +1 -0
  69. package/dist/tests/thrownErrorKeys.unit.test.js +59 -0
  70. package/dist/tests/thrownErrorKeys.unit.test.js.map +1 -0
  71. package/dist/tests/translationKey.unit.test.js +13 -1
  72. package/dist/tests/translationKey.unit.test.js.map +1 -1
  73. package/dist/translationKey.d.ts +7 -1
  74. package/dist/translationKey.d.ts.map +1 -1
  75. package/dist/translationKey.js +4 -1
  76. package/dist/translationKey.js.map +1 -1
  77. package/dist/translator.d.ts +2 -1
  78. package/dist/translator.d.ts.map +1 -1
  79. package/dist/translator.js.map +1 -1
  80. package/dist/types.d.ts +2 -1
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +2 -2
  83. package/src/direction.ts +36 -0
  84. package/src/errorMessage.ts +37 -0
  85. package/src/index.ts +5 -1
  86. package/src/locales/ar/translation.json +21 -4
  87. package/src/locales/az/translation.json +21 -4
  88. package/src/locales/cs/translation.json +21 -4
  89. package/src/locales/de/translation.json +21 -4
  90. package/src/locales/el/translation.json +21 -4
  91. package/src/locales/en/translation.json +21 -4
  92. package/src/locales/es/translation.json +21 -4
  93. package/src/locales/fi/translation.json +21 -4
  94. package/src/locales/fr/translation.json +21 -4
  95. package/src/locales/hi/translation.json +21 -4
  96. package/src/locales/hu/translation.json +21 -4
  97. package/src/locales/id/translation.json +21 -4
  98. package/src/locales/it/translation.json +21 -4
  99. package/src/locales/ja/translation.json +21 -4
  100. package/src/locales/jv/translation.json +21 -4
  101. package/src/locales/ko/translation.json +21 -4
  102. package/src/locales/ml/translation.json +21 -4
  103. package/src/locales/ms/translation.json +21 -4
  104. package/src/locales/nl/translation.json +21 -4
  105. package/src/locales/no/translation.json +21 -4
  106. package/src/locales/pl/translation.json +21 -4
  107. package/src/locales/pt/translation.json +21 -4
  108. package/src/locales/pt-BR/translation.json +21 -4
  109. package/src/locales/ro/translation.json +21 -4
  110. package/src/locales/ru/translation.json +21 -4
  111. package/src/locales/sr/translation.json +21 -4
  112. package/src/locales/sv/translation.json +21 -4
  113. package/src/locales/th/translation.json +21 -4
  114. package/src/locales/tr/translation.json +21 -4
  115. package/src/locales/uk/translation.json +21 -4
  116. package/src/locales/vi/translation.json +21 -4
  117. package/src/locales/zh-CN/translation.json +21 -4
  118. package/src/tests/direction.unit.test.ts +52 -0
  119. package/src/tests/errorMessage.unit.test.ts +84 -0
  120. package/src/tests/i18nFrontend.unit.test.ts +25 -14
  121. package/src/tests/locale.test-d.ts +26 -2
  122. package/src/tests/thrownErrorKeys.unit.test.ts +91 -0
  123. package/src/tests/translationKey.unit.test.ts +15 -0
  124. package/src/translationKey.ts +14 -5
  125. package/src/translator.ts +3 -2
  126. package/src/types.ts +6 -1
  127. package/tsconfig.tsbuildinfo +1 -1
@@ -10,7 +10,9 @@
10
10
  "INVALID_SIGNATURE": "Chữ ký không hợp lệ",
11
11
  "SITE_KEY_MISSING": "SITE KEY thiếu",
12
12
  "ACCOUNT_NOT_FOUND": "Không tìm thấy tài khoản",
13
- "INVALID_TIMESTAMP": "Thời điểm không hợp lệ"
13
+ "INVALID_TIMESTAMP": "Thời điểm không hợp lệ",
14
+ "MISSING_AUTH_HEADER": "Thiếu tiêu đề ủy quyền",
15
+ "INVALID_JWT": "JWT không hợp lệ"
14
16
  },
15
17
  "API": {
16
18
  "INSUFFICIENT_PERMISSIONS": "Không đủ quyền để thực hiện hành động này",
@@ -54,7 +56,8 @@
54
56
  "MOBILE_BLOCKED": "Các yêu cầu từ kết nối mạng di động không được phép cho trang web này",
55
57
  "SATELLITE_BLOCKED": "Các yêu cầu từ kết nối vệ tinh không được phép cho trang web này",
56
58
  "CRAWLER_BLOCKED": "Các yêu cầu từ trình thu thập dữ liệu đã biết không được phép cho trang web này",
57
- "DISALLOWED_WEBVIEW": "Yêu cầu từ trình duyệt trong ứng dụng (WebView) không được phép cho trang web này"
59
+ "DISALLOWED_WEBVIEW": "Yêu cầu từ trình duyệt trong ứng dụng (WebView) không được phép cho trang web này",
60
+ "INTERNAL_SERVER_ERROR": "Lỗi máy chủ nội bộ"
58
61
  },
59
62
  "PORTAL": {
60
63
  "CANNOT_MODIFY_OWNER_USER": "Không thể sửa đổi người dùng chủ sở hữu",
@@ -381,6 +384,11 @@
381
384
  "INSECURE_CONTEXT": "Procaptcha yêu cầu kết nối an toàn (HTTPS)",
382
385
  "CHECKING": "Đang xác minh bạn là người thật",
383
386
  "IMAGE_DIALOG_LABEL": "Thử thách hình ảnh",
387
+ "CANNOT_LOAD": "Không thể tải CAPTCHA",
388
+ "RELOAD": "Tải lại",
389
+ "VERIFIED_AGENT": "Tác nhân đã xác minh",
390
+ "TRUSTED_REQUEST": "Yêu cầu đáng tin cậy",
391
+ "LOADING": "Đang tải",
384
392
  "PUZZLE": {
385
393
  "DIALOG_LABEL": "Thử thách ghép hình",
386
394
  "DRAG": "Kéo mảnh ghép đến vị trí đích",
@@ -436,7 +444,13 @@
436
444
  "TABLES_UNDEFINED": "Bảng chưa xác định",
437
445
  "CONNECTION_UNDEFINED": "Kết nối không xác định",
438
446
  "COMMITMENT_FLAG_FAILED": "Không thể đánh dấu cam kết là đã xử lý",
439
- "UNKNOWN": "Lỗi cơ sở dữ liệu không xác định"
447
+ "UNKNOWN": "Lỗi cơ sở dữ liệu không xác định",
448
+ "QUERY_ERROR": "Truy vấn cơ sở dữ liệu thất bại",
449
+ "REDIS_ACCESS_RULES_CONNECTION_UNDEFINED": "Chưa xác định kết nối Redis cho quy tắc truy cập",
450
+ "USER_ACCESS_RULES_STORAGE_UNDEFINED": "Chưa xác định bộ lưu trữ quy tắc truy cập người dùng",
451
+ "SESSION_STORE_FAILED": "Không thể lưu phiên",
452
+ "SESSION_CHECK_REMOVE_FAILED": "Không thể kiểm tra và xóa phiên",
453
+ "SESSION_GET_FAILED": "Không thể lấy phiên"
440
454
  },
441
455
  "CAPTCHA": {
442
456
  "PARSE_ERROR": "Lỗi phân tích captcha",
@@ -455,7 +469,10 @@
455
469
  "INVALID_SALT": "Salt không hợp lệ",
456
470
  "INVALID_TOKEN": "Token không hợp lệ",
457
471
  "INVALID_SOLUTION": "Giải pháp không hợp lệ",
458
- "DECRYPTION_ERROR": "Lỗi giải mã"
472
+ "DECRYPTION_ERROR": "Lỗi giải mã",
473
+ "SOLUTION_NOT_FOUND": "Không tìm thấy lời giải",
474
+ "DECISION_MACHINE_DENIED": "Bị máy quyết định từ chối",
475
+ "INVALID_TIMESTAMP": "Thời điểm không hợp lệ"
459
476
  },
460
477
  "CLI": {
461
478
  "PARAMETER_ERROR": "Tham số không hợp lệ"
@@ -10,7 +10,9 @@
10
10
  "INVALID_SIGNATURE": "无效的签名",
11
11
  "SITE_KEY_MISSING": "缺少SITE KEY",
12
12
  "ACCOUNT_NOT_FOUND": "找不到帐户",
13
- "INVALID_TIMESTAMP": "无效的时间戳"
13
+ "INVALID_TIMESTAMP": "无效的时间戳",
14
+ "MISSING_AUTH_HEADER": "缺少授权标头",
15
+ "INVALID_JWT": "无效的 JWT"
14
16
  },
15
17
  "API": {
16
18
  "INSUFFICIENT_PERMISSIONS": "权限不足,无法执行此操作",
@@ -54,7 +56,8 @@
54
56
  "MOBILE_BLOCKED": "此网站不允许来自移动网络连接的请求",
55
57
  "SATELLITE_BLOCKED": "此网站不允许来自卫星连接的请求",
56
58
  "CRAWLER_BLOCKED": "此网站不允许来自已知爬虫的请求",
57
- "DISALLOWED_WEBVIEW": "此站点不允许来自应用内浏览器 (WebView) 的请求"
59
+ "DISALLOWED_WEBVIEW": "此站点不允许来自应用内浏览器 (WebView) 的请求",
60
+ "INTERNAL_SERVER_ERROR": "服务器内部错误"
58
61
  },
59
62
  "PORTAL": {
60
63
  "CANNOT_MODIFY_OWNER_USER": "无法修改所有者用户",
@@ -381,6 +384,11 @@
381
384
  "INSECURE_CONTEXT": "Procaptcha 需要安全连接 (HTTPS)",
382
385
  "CHECKING": "正在验证您是真人",
383
386
  "IMAGE_DIALOG_LABEL": "图片验证",
387
+ "CANNOT_LOAD": "无法加载验证码",
388
+ "RELOAD": "重新加载",
389
+ "VERIFIED_AGENT": "已验证的代理",
390
+ "TRUSTED_REQUEST": "受信任的请求",
391
+ "LOADING": "加载中",
384
392
  "PUZZLE": {
385
393
  "DIALOG_LABEL": "拼图验证",
386
394
  "DRAG": "将拼块拖到目标位置",
@@ -436,7 +444,13 @@
436
444
  "TABLES_UNDEFINED": "未定义表",
437
445
  "CONNECTION_UNDEFINED": "连接未定义",
438
446
  "COMMITMENT_FLAG_FAILED": "标记承诺为已处理失败",
439
- "UNKNOWN": "未知数据库错误"
447
+ "UNKNOWN": "未知数据库错误",
448
+ "QUERY_ERROR": "数据库查询失败",
449
+ "REDIS_ACCESS_RULES_CONNECTION_UNDEFINED": "访问规则的 Redis 连接未定义",
450
+ "USER_ACCESS_RULES_STORAGE_UNDEFINED": "用户访问规则存储未定义",
451
+ "SESSION_STORE_FAILED": "存储会话失败",
452
+ "SESSION_CHECK_REMOVE_FAILED": "检查并移除会话失败",
453
+ "SESSION_GET_FAILED": "获取会话失败"
440
454
  },
441
455
  "CAPTCHA": {
442
456
  "PARSE_ERROR": "解析验证码时出错",
@@ -455,7 +469,10 @@
455
469
  "INVALID_SALT": "盐值无效",
456
470
  "INVALID_TOKEN": "无效的令牌",
457
471
  "INVALID_SOLUTION": "无效的解决方案",
458
- "DECRYPTION_ERROR": "解密错误"
472
+ "DECRYPTION_ERROR": "解密错误",
473
+ "SOLUTION_NOT_FOUND": "未找到解答",
474
+ "DECISION_MACHINE_DENIED": "被决策机拒绝",
475
+ "INVALID_TIMESTAMP": "无效的时间戳"
459
476
  },
460
477
  "CLI": {
461
478
  "PARAMETER_ERROR": "无效的参数"
@@ -0,0 +1,52 @@
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 { getLanguageDirection } from "../direction.js";
17
+ import { LanguageCodes } from "../translations.js";
18
+
19
+ describe("getLanguageDirection", () => {
20
+ test("is rtl for Arabic", () => {
21
+ expect(getLanguageDirection("ar")).toBe("rtl");
22
+ });
23
+
24
+ test("is rtl for regional and differently-cased Arabic tags", () => {
25
+ expect(getLanguageDirection("ar-EG")).toBe("rtl");
26
+ expect(getLanguageDirection("AR_sa")).toBe("rtl");
27
+ });
28
+
29
+ test("is rtl for other right-to-left scripts", () => {
30
+ for (const language of ["he", "fa", "ur", "yi"]) {
31
+ expect(getLanguageDirection(language)).toBe("rtl");
32
+ }
33
+ });
34
+
35
+ test("is ltr for every other shipped language", () => {
36
+ const others: string[] = LanguageCodes.filter(
37
+ (code: string) => code !== "ar",
38
+ );
39
+ for (const language of others) {
40
+ expect(getLanguageDirection(language)).toBe("ltr");
41
+ }
42
+ });
43
+
44
+ test("is ltr for an empty or unknown tag", () => {
45
+ expect(getLanguageDirection("")).toBe("ltr");
46
+ expect(getLanguageDirection("xx")).toBe("ltr");
47
+ });
48
+
49
+ test("matches only the primary subtag", () => {
50
+ expect(getLanguageDirection("en-AR")).toBe("ltr");
51
+ });
52
+ });
@@ -0,0 +1,84 @@
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
+ import { flatten } from "@prosopo/util";
15
+ import { describe, expect, test } from "vitest";
16
+ import { localiseErrorMessage } from "../errorMessage.js";
17
+ import de from "../locales/de/translation.json" with { type: "json" };
18
+ import type { Ti18n, TranslateOptions } from "../types.js";
19
+
20
+ // Same lookup order as the browser instance in i18nFrontend.ts: the active
21
+ // catalogue, then the caller's defaultValue, then the key.
22
+ const i18nIn = (
23
+ catalogue: Record<string, unknown>,
24
+ isInitialized = true,
25
+ ): Ti18n => {
26
+ const flat: Record<string, string> = flatten(catalogue);
27
+ return {
28
+ language: "de",
29
+ isInitialized,
30
+ t: (key: string, options?: TranslateOptions): string =>
31
+ flat[key] ?? options?.defaultValue ?? key,
32
+ changeLanguage: async (): Promise<void> => undefined,
33
+ hasLoadedNamespace: (): boolean => isInitialized,
34
+ on: (): void => undefined,
35
+ off: (): void => undefined,
36
+ };
37
+ };
38
+
39
+ const germanI18n = (): Ti18n => i18nIn(de);
40
+
41
+ describe("localiseErrorMessage", () => {
42
+ test("translates a catalogue key into the widget's language, not the server's", () => {
43
+ const i18n = germanI18n();
44
+ expect(
45
+ localiseErrorMessage(i18n, {
46
+ message: "Invalid site key",
47
+ key: "API.INVALID_SITE_KEY",
48
+ }),
49
+ ).toBe("Ungültiger Site-Schlüssel");
50
+ });
51
+
52
+ test("keeps the server message when the key is not in the catalogue", () => {
53
+ const i18n = germanI18n();
54
+ expect(
55
+ localiseErrorMessage(i18n, {
56
+ message: "upstream timed out",
57
+ key: "API.NOT_A_REAL_KEY",
58
+ }),
59
+ ).toBe("upstream timed out");
60
+ });
61
+
62
+ test("keeps the server message when there is no key", () => {
63
+ const i18n = germanI18n();
64
+ expect(localiseErrorMessage(i18n, { message: "Invalid site key" })).toBe(
65
+ "Invalid site key",
66
+ );
67
+ });
68
+
69
+ test("keeps the server message until i18n is initialised", () => {
70
+ const i18n = i18nIn(de, false);
71
+ expect(
72
+ localiseErrorMessage(i18n, {
73
+ message: "Invalid site key",
74
+ key: "API.INVALID_SITE_KEY",
75
+ }),
76
+ ).toBe("Invalid site key");
77
+ expect(
78
+ localiseErrorMessage(undefined, {
79
+ message: "Invalid site key",
80
+ key: "API.INVALID_SITE_KEY",
81
+ }),
82
+ ).toBe("Invalid site key");
83
+ });
84
+ });
@@ -220,22 +220,30 @@ describe("translating", () => {
220
220
 
221
221
  test("substitutes interpolation values", async () => {
222
222
  const harness = await load({
223
- catalogues: { en: { POSITION: "{{x}} across, {{y}} down" } },
223
+ catalogues: {
224
+ en: { WIDGET: { PUZZLE: { POSITION: "{{x}} across, {{y}} down" } } },
225
+ },
224
226
  });
225
227
 
226
- expect((await initialised(harness)).t("POSITION", { x: 10, y: 20 })).toBe(
227
- "10 across, 20 down",
228
- );
228
+ expect(
229
+ (await initialised(harness)).t("WIDGET.PUZZLE.POSITION", {
230
+ x: 10,
231
+ y: 20,
232
+ }),
233
+ ).toBe("10 across, 20 down");
229
234
  });
230
235
 
231
236
  test("falls back to English when the translation lacks the key", async () => {
232
237
  const harness = await load({
233
- catalogues: { fr: { KEPT: "gardé" }, en: { KEPT: "kept", ONLY: "only" } },
238
+ catalogues: {
239
+ fr: { WIDGET: { SUBMIT: "gardé" } },
240
+ en: { WIDGET: { SUBMIT: "kept", CANCEL: "only" } },
241
+ },
234
242
  });
235
243
  const i18n = await initialised(harness, "fr");
236
244
 
237
- expect(i18n.t("KEPT")).toBe("gardé");
238
- expect(i18n.t("ONLY")).toBe("only");
245
+ expect(i18n.t("WIDGET.SUBMIT")).toBe("gardé");
246
+ expect(i18n.t("WIDGET.CANCEL")).toBe("only");
239
247
  });
240
248
 
241
249
  test("falls back to the caller's default when no catalogue has the key", async () => {
@@ -252,7 +260,7 @@ describe("translating", () => {
252
260
  const harness = await load({ catalogues: { en: {} } });
253
261
 
254
262
  expect(
255
- (await initialised(harness)).t("POSITION", {
263
+ (await initialised(harness)).t("WIDGET.PUZZLE.POSITION", {
256
264
  defaultValue: "{{x}} across",
257
265
  x: 7,
258
266
  }),
@@ -262,17 +270,17 @@ describe("translating", () => {
262
270
  test("falls back to the key when there is no default either", async () => {
263
271
  const harness = await load({ catalogues: { en: {} } });
264
272
 
265
- expect((await initialised(harness)).t("WIDGET.MISSING")).toBe(
266
- "WIDGET.MISSING",
273
+ expect((await initialised(harness)).t("WIDGET.SUBMIT")).toBe(
274
+ "WIDGET.SUBMIT",
267
275
  );
268
276
  });
269
277
 
270
278
  test("leaves a placeholder alone when no value was supplied for it", async () => {
271
279
  const harness = await load({
272
- catalogues: { en: { GREET: "hello {{name}}" } },
280
+ catalogues: { en: { WIDGET: { SUBMIT: "hello {{name}}" } } },
273
281
  });
274
282
 
275
- expect((await initialised(harness)).t("GREET", { other: 1 })).toBe(
283
+ expect((await initialised(harness)).t("WIDGET.SUBMIT", { other: 1 })).toBe(
276
284
  "hello {{name}}",
277
285
  );
278
286
  });
@@ -304,14 +312,17 @@ describe("when the catalogue cannot be fetched", () => {
304
312
  describe("switching language", () => {
305
313
  test("loads the new catalogue and translates against it", async () => {
306
314
  const harness = await load({
307
- catalogues: { en: { YES: "yes" }, de: { YES: "ja" } },
315
+ catalogues: {
316
+ en: { WIDGET: { SUBMIT: "yes" } },
317
+ de: { WIDGET: { SUBMIT: "ja" } },
318
+ },
308
319
  });
309
320
  const i18n = await initialised(harness, "en");
310
321
 
311
322
  await i18n.changeLanguage("de");
312
323
 
313
324
  expect(i18n.language).toBe("de");
314
- expect(i18n.t("YES")).toBe("ja");
325
+ expect(i18n.t("WIDGET.SUBMIT")).toBe("ja");
315
326
  });
316
327
 
317
328
  test("notifies subscribers", async () => {
@@ -22,11 +22,13 @@ import {
22
22
  LanguageCodes,
23
23
  Languages,
24
24
  type Ti18n,
25
+ type TranslateFn,
25
26
  type TranslationKey,
26
27
  createTranslator,
27
28
  i18nMiddleware,
28
29
  isClientSide,
29
30
  isLanguage,
31
+ isTranslationKey,
30
32
  loadI18next,
31
33
  translationKeys,
32
34
  } from "../index.js";
@@ -67,8 +69,30 @@ describe("isLanguage", () => {
67
69
  });
68
70
 
69
71
  describe("TranslationKey", () => {
70
- it("is a plain string — keys are read from JSON, never narrowed", () => {
71
- expectTypeOf<TranslationKey>().toEqualTypeOf<string>();
72
+ it("is the union of the English catalogue's leaf paths", () => {
73
+ expectTypeOf<"WIDGET.I_AM_HUMAN">().toExtend<TranslationKey>();
74
+ expectTypeOf<"WIDGET.PUZZLE.RETRY">().toExtend<TranslationKey>();
75
+ expectTypeOf<string>().not.toExtend<TranslationKey>();
76
+ });
77
+
78
+ it("rejects a mistyped key", () => {
79
+ // @ts-expect-error not a key in the English catalogue
80
+ assertType<TranslationKey>("WIDGET.I_AM_HUMANN");
81
+ });
82
+
83
+ it("rejects a branch, which has no text of its own", () => {
84
+ // @ts-expect-error WIDGET is an object, not a leaf
85
+ assertType<TranslationKey>("WIDGET");
86
+ });
87
+
88
+ it("rejects a mistyped key at a translate call", () => {
89
+ const translate: TranslateFn = (key: TranslationKey): string => key;
90
+ // @ts-expect-error not a key in the English catalogue
91
+ translate("API.UNKNOWNN");
92
+ });
93
+
94
+ it("narrows a runtime string with isTranslationKey", () => {
95
+ expectTypeOf(isTranslationKey).guards.toEqualTypeOf<TranslationKey>();
72
96
  });
73
97
 
74
98
  it("accepts a key literal, so error call sites need no cast", () => {
@@ -0,0 +1,91 @@
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
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { describe, expect, test } from "vitest";
17
+ import translationEn from "../locales/en/translation.json" with {
18
+ type: "json",
19
+ };
20
+
21
+ // Error classes and ResultReason carry a catalogue key. A key with no entry
22
+ // reaches users and logs untranslated, so every one used in the workspace's
23
+ // source must exist in the English catalogue (the parity test in
24
+ // locales.unit.test.ts carries it to every other locale).
25
+
26
+ const PACKAGES_DIR = path.resolve("..");
27
+ const THROWN_KEY = /Error\(\s*["']([A-Z][A-Z_]*\.[A-Z0-9_]+)["']/g;
28
+ const REASON_KEY = /=\s*"([A-Z][A-Z_]*\.[A-Z0-9_]+)"/g;
29
+
30
+ const isCatalogueKey = (key: string): boolean => {
31
+ const [section, name] = key.split(".");
32
+ const entries: Record<string, unknown> | undefined =
33
+ section && section in translationEn
34
+ ? translationEn[section as keyof typeof translationEn]
35
+ : undefined;
36
+ return name !== undefined && entries !== undefined && name in entries;
37
+ };
38
+
39
+ const sourceFiles = (dir: string): string[] =>
40
+ fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry: fs.Dirent) => {
41
+ const full = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ return entry.name === "tests" || entry.name === "node_modules"
44
+ ? []
45
+ : sourceFiles(full);
46
+ }
47
+ return entry.name.endsWith(".ts") && !entry.name.includes(".test.")
48
+ ? [full]
49
+ : [];
50
+ });
51
+
52
+ const workspaceSources = (): string[] =>
53
+ fs
54
+ .readdirSync(PACKAGES_DIR)
55
+ .map((name: string) => path.join(PACKAGES_DIR, name, "src"))
56
+ .filter((dir: string) => fs.existsSync(dir))
57
+ .flatMap(sourceFiles);
58
+
59
+ const uncataloguedKeys = (files: string[], pattern: RegExp): string[] => {
60
+ const missing = new Set<string>();
61
+ for (const file of files) {
62
+ for (const match of fs.readFileSync(file, "utf8").matchAll(pattern)) {
63
+ const key = match[1];
64
+ if (key && !isCatalogueKey(key)) {
65
+ missing.add(`${key} (${path.relative(PACKAGES_DIR, file)})`);
66
+ }
67
+ }
68
+ }
69
+ return [...missing].sort();
70
+ };
71
+
72
+ describe("error keys", () => {
73
+ test("every key an error class is constructed with is in the catalogue", () => {
74
+ expect(uncataloguedKeys(workspaceSources(), THROWN_KEY)).toEqual([]);
75
+ });
76
+
77
+ test("every ResultReason is in the catalogue", () => {
78
+ const reasons = path.join(PACKAGES_DIR, "types/src/provider/reasons.ts");
79
+ expect(uncataloguedKeys([reasons], REASON_KEY)).toEqual([]);
80
+ });
81
+
82
+ test("the scan finds the keys it is looking for", () => {
83
+ const found = workspaceSources().flatMap((file: string) =>
84
+ [...fs.readFileSync(file, "utf8").matchAll(THROWN_KEY)].map(
85
+ (match: RegExpMatchArray) => match[1],
86
+ ),
87
+ );
88
+ expect(found).toContain("DATABASE.DATABASE_IMPORT_FAILED");
89
+ expect(found.length).toBeGreaterThan(100);
90
+ });
91
+ });
@@ -19,6 +19,7 @@ import translationEn from "../locales/en/translation.json" with {
19
19
  import {
20
20
  type TranslationNode,
21
21
  getLeafFieldPath,
22
+ isTranslationKey,
22
23
  translationKeys,
23
24
  } from "../translationKey.js";
24
25
 
@@ -138,3 +139,17 @@ describe("translationKeys", () => {
138
139
  expect(translationKeys).not.toContain("");
139
140
  });
140
141
  });
142
+
143
+ describe("isTranslationKey", () => {
144
+ test("accepts a leaf of the English catalogue", () => {
145
+ expect(isTranslationKey("WIDGET.I_AM_HUMAN")).toBe(true);
146
+ expect(isTranslationKey("API.INVALID_SITE_KEY")).toBe(true);
147
+ });
148
+
149
+ test("rejects a section, a mistyped key, free text and the empty string", () => {
150
+ expect(isTranslationKey("WIDGET")).toBe(false);
151
+ expect(isTranslationKey("WIDGET.I_AM_HUMANN")).toBe(false);
152
+ expect(isTranslationKey("Invalid site key")).toBe(false);
153
+ expect(isTranslationKey("")).toBe(false);
154
+ });
155
+ });
@@ -57,8 +57,17 @@ export function getLeafFieldPath(obj: TranslationNode): string[] {
57
57
 
58
58
  export const translationKeys: string[] = getLeafFieldPath(translationEn);
59
59
 
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;
60
+ type LeafPath<Node> = {
61
+ [Key in keyof Node & string]: Node[Key] extends string
62
+ ? Key
63
+ : `${Key}.${LeafPath<Node[Key]>}`;
64
+ }[keyof Node & string];
65
+
66
+ /** A leaf path in the English catalogue, so a mistyped key fails to compile. */
67
+ export type TranslationKey = LeafPath<typeof translationEn>;
68
+
69
+ const translationKeySet: ReadonlySet<string> = new Set(translationKeys);
70
+
71
+ /** For keys that only exist at runtime, e.g. an error message. */
72
+ export const isTranslationKey = (value: string): value is TranslationKey =>
73
+ translationKeySet.has(value);
package/src/translator.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  // limitations under the License.
14
14
 
15
15
  import initializeI18n from "./i18nFrontend.js";
16
+ import type { TranslationKey } from "./translationKey.js";
16
17
  import type { Ti18n, TranslateOptions } from "./types.js";
17
18
 
18
19
  const NAMESPACE = "translation";
@@ -23,7 +24,7 @@ export interface Translator {
23
24
  * interpolation values and the `defaultValue` a widget falls back to when
24
25
  * its key has not been added to the bundled catalogues yet.
25
26
  */
26
- t(key: string, options?: TranslateOptions): string;
27
+ t(key: TranslationKey, options?: TranslateOptions): string;
27
28
  /**
28
29
  * Whether the namespace for the active language has finished loading.
29
30
  * Mirrors `ready` from the old react-i18next hook: widgets render an empty
@@ -49,7 +50,7 @@ export const createTranslator = (existing?: Ti18n): Translator => {
49
50
  i18n.isInitialized && i18n.hasLoadedNamespace(NAMESPACE);
50
51
 
51
52
  return {
52
- t: (key: string, options?: TranslateOptions): string =>
53
+ t: (key: TranslationKey, options?: TranslateOptions): string =>
53
54
  i18n.t(key, options),
54
55
  isReady,
55
56
  i18n,
package/src/types.ts CHANGED
@@ -12,6 +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 { TranslationKey } from "./translationKey.js";
16
+
15
17
  /**
16
18
  * Interpolation values, plus the `defaultValue` a caller falls back to when its
17
19
  * key is not in the catalogue. Values are substituted into `{{name}}`
@@ -22,7 +24,10 @@ export interface TranslateOptions {
22
24
  [value: string]: unknown;
23
25
  }
24
26
 
25
- export type TranslateFn = (key: string, options?: TranslateOptions) => string;
27
+ export type TranslateFn = (
28
+ key: TranslationKey,
29
+ options?: TranslateOptions,
30
+ ) => string;
26
31
 
27
32
  export type I18nEvent = "initialized" | "loaded" | "languageChanged";
28
33