@prosopo/locale 3.6.2 → 3.7.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 (106) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +3 -6
  2. package/.turbo/turbo-build$colon$tsc.log +6 -9
  3. package/.turbo/turbo-build.log +5 -11
  4. package/CHANGELOG.md +16 -0
  5. package/LICENSE +201 -0
  6. package/dist/cjs/locales/en/translation.cjs +2 -1
  7. package/dist/locales/ar/translation.json +2 -1
  8. package/dist/locales/az/translation.json +2 -1
  9. package/dist/locales/cs/translation.json +2 -1
  10. package/dist/locales/de/translation.json +2 -1
  11. package/dist/locales/el/translation.json +2 -1
  12. package/dist/locales/en/translation.js +2 -1
  13. package/dist/locales/en/translation.json +2 -1
  14. package/dist/locales/es/translation.json +2 -1
  15. package/dist/locales/fi/translation.json +2 -1
  16. package/dist/locales/fr/translation.json +2 -1
  17. package/dist/locales/hi/translation.json +2 -1
  18. package/dist/locales/hu/translation.json +2 -1
  19. package/dist/locales/id/translation.json +2 -1
  20. package/dist/locales/it/translation.json +2 -1
  21. package/dist/locales/ja/translation.json +2 -1
  22. package/dist/locales/jv/translation.json +2 -1
  23. package/dist/locales/ko/translation.json +2 -1
  24. package/dist/locales/ml/translation.json +2 -1
  25. package/dist/locales/ms/translation.json +2 -1
  26. package/dist/locales/nl/translation.json +2 -1
  27. package/dist/locales/no/translation.json +2 -1
  28. package/dist/locales/pl/translation.json +2 -1
  29. package/dist/locales/pt/translation.json +2 -1
  30. package/dist/locales/pt-BR/translation.json +2 -1
  31. package/dist/locales/ro/translation.json +2 -1
  32. package/dist/locales/ru/translation.json +2 -1
  33. package/dist/locales/sr/translation.json +2 -1
  34. package/dist/locales/sv/translation.json +2 -1
  35. package/dist/locales/th/translation.json +2 -1
  36. package/dist/locales/tr/translation.json +2 -1
  37. package/dist/locales/uk/translation.json +2 -1
  38. package/dist/locales/vi/translation.json +2 -1
  39. package/dist/locales/zh-CN/translation.json +2 -1
  40. package/package.json +65 -66
  41. package/src/direction.ts +0 -36
  42. package/src/errorMessage.ts +0 -37
  43. package/src/i18SharedOptions.ts +0 -29
  44. package/src/i18nBackend.ts +0 -69
  45. package/src/i18nFrontend.ts +0 -267
  46. package/src/i18nMiddleware.ts +0 -35
  47. package/src/index.ts +0 -33
  48. package/src/loadI18next.ts +0 -136
  49. package/src/locales/ar/translation.json +0 -493
  50. package/src/locales/az/translation.json +0 -493
  51. package/src/locales/cs/translation.json +0 -493
  52. package/src/locales/de/translation.json +0 -493
  53. package/src/locales/el/translation.json +0 -493
  54. package/src/locales/en/translation.json +0 -493
  55. package/src/locales/es/translation.json +0 -493
  56. package/src/locales/fi/translation.json +0 -493
  57. package/src/locales/fr/translation.json +0 -493
  58. package/src/locales/hi/translation.json +0 -493
  59. package/src/locales/hu/translation.json +0 -493
  60. package/src/locales/id/translation.json +0 -493
  61. package/src/locales/it/translation.json +0 -493
  62. package/src/locales/ja/translation.json +0 -493
  63. package/src/locales/jv/translation.json +0 -493
  64. package/src/locales/ko/translation.json +0 -493
  65. package/src/locales/ml/translation.json +0 -493
  66. package/src/locales/ms/translation.json +0 -493
  67. package/src/locales/nl/translation.json +0 -493
  68. package/src/locales/no/translation.json +0 -493
  69. package/src/locales/pl/translation.json +0 -493
  70. package/src/locales/pt/translation.json +0 -493
  71. package/src/locales/pt-BR/translation.json +0 -493
  72. package/src/locales/ro/translation.json +0 -493
  73. package/src/locales/ru/translation.json +0 -493
  74. package/src/locales/sr/translation.json +0 -493
  75. package/src/locales/sv/translation.json +0 -493
  76. package/src/locales/th/translation.json +0 -493
  77. package/src/locales/tr/translation.json +0 -493
  78. package/src/locales/uk/translation.json +0 -493
  79. package/src/locales/vi/translation.json +0 -493
  80. package/src/locales/zh-CN/translation.json +0 -493
  81. package/src/process.ts +0 -29
  82. package/src/tests/direction.unit.test.ts +0 -52
  83. package/src/tests/errorMessage.unit.test.ts +0 -84
  84. package/src/tests/i18SharedOptions.unit.test.ts +0 -139
  85. package/src/tests/i18nBackend.unit.test.ts +0 -241
  86. package/src/tests/i18nFrontend.unit.test.ts +0 -404
  87. package/src/tests/i18nMiddleware.unit.test.ts +0 -159
  88. package/src/tests/loadI18next.unit.test.ts +0 -443
  89. package/src/tests/locale.test-d.ts +0 -184
  90. package/src/tests/localePlaceholders.unit.test.ts +0 -81
  91. package/src/tests/locales.unit.test.ts +0 -79
  92. package/src/tests/process.unit.test.ts +0 -33
  93. package/src/tests/thrownErrorKeys.unit.test.ts +0 -91
  94. package/src/tests/translationKey.unit.test.ts +0 -155
  95. package/src/tests/translations.unit.test.ts +0 -97
  96. package/src/tests/translator.unit.test.ts +0 -213
  97. package/src/tests/util.unit.test.ts +0 -102
  98. package/src/translationKey.ts +0 -73
  99. package/src/translations.ts +0 -55
  100. package/src/translator.ts +0 -69
  101. package/src/types.ts +0 -47
  102. package/src/util.ts +0 -25
  103. package/tsconfig.cjs.json +0 -21
  104. package/tsconfig.json +0 -22
  105. package/tsconfig.tsbuildinfo +0 -1
  106. package/tsconfig.types.json +0 -9
@@ -398,7 +398,8 @@
398
398
  "KEYBOARD_HINT": "Померајте део стрелицама, уз држање тастера Shift за ситније кораке. Притисните Enter да га пошаљете, Home да га вратите на почетак или Escape да откажете.",
399
399
  "POSITION": "{{x}} посто с лева, {{y}} посто одозго",
400
400
  "CHECKING": "Проверавамо ваш одговор",
401
- "RETRY_ANNOUNCEMENT": "За длаку. Учитана је нова слагалица и део је поново на почетку."
401
+ "RETRY_ANNOUNCEMENT": "За длаку. Учитана је нова слагалица и део је поново на почетку.",
402
+ "REFRESH": "Друга слагалица"
402
403
  }
403
404
  },
404
405
  "CONTRACT": {
@@ -398,7 +398,8 @@
398
398
  "KEYBOARD_HINT": "Flytta biten med piltangenterna, håll ned skift för mindre steg. Tryck på Retur för att skicka in den, Home för att lägga tillbaka den vid starten, eller Escape för att avbryta.",
399
399
  "POSITION": "{{x}} procent från vänster, {{y}} procent uppifrån",
400
400
  "CHECKING": "Kontrollerar ditt svar",
401
- "RETRY_ANNOUNCEMENT": "Nästan. Ett nytt pussel har laddats och biten är tillbaka vid starten."
401
+ "RETRY_ANNOUNCEMENT": "Nästan. Ett nytt pussel har laddats och biten är tillbaka vid starten.",
402
+ "REFRESH": "Ett annat pussel"
402
403
  }
403
404
  },
404
405
  "CONTRACT": {
@@ -398,7 +398,8 @@
398
398
  "KEYBOARD_HINT": "ใช้ปุ่มลูกศรเพื่อเลื่อนชิ้นส่วน กดปุ่ม Shift ค้างไว้เพื่อเลื่อนทีละน้อย กด Enter เพื่อส่ง กด Home เพื่อกลับไปยังจุดเริ่มต้น หรือกด Escape เพื่อยกเลิก",
399
399
  "POSITION": "{{x}} เปอร์เซ็นต์จากด้านซ้าย {{y}} เปอร์เซ็นต์จากด้านบน",
400
400
  "CHECKING": "กำลังตรวจคำตอบของคุณ",
401
- "RETRY_ANNOUNCEMENT": "เกือบแล้ว มีจิ๊กซอว์ชุดใหม่โหลดขึ้นมา และชิ้นส่วนกลับไปอยู่ที่จุดเริ่มต้น"
401
+ "RETRY_ANNOUNCEMENT": "เกือบแล้ว มีจิ๊กซอว์ชุดใหม่โหลดขึ้นมา และชิ้นส่วนกลับไปอยู่ที่จุดเริ่มต้น",
402
+ "REFRESH": "จิ๊กซอว์ชุดอื่น"
402
403
  }
403
404
  },
404
405
  "CONTRACT": {
@@ -398,7 +398,8 @@
398
398
  "KEYBOARD_HINT": "Parçayı ok tuşlarıyla taşıyın, daha küçük adımlar için Shift tuşunu basılı tutun. Göndermek için Enter'a, başa döndürmek için Home'a, iptal etmek için Escape'e basın.",
399
399
  "POSITION": "soldan yüzde {{x}}, yukarıdan yüzde {{y}}",
400
400
  "CHECKING": "Cevabınız kontrol ediliyor",
401
- "RETRY_ANNOUNCEMENT": "Az kaldı. Yeni bir yapboz yüklendi ve parça yeniden başlangıçta."
401
+ "RETRY_ANNOUNCEMENT": "Az kaldı. Yeni bir yapboz yüklendi ve parça yeniden başlangıçta.",
402
+ "REFRESH": "Başka bir yapboz"
402
403
  }
403
404
  },
404
405
  "CONTRACT": {
@@ -398,7 +398,8 @@
398
398
  "KEYBOARD_HINT": "Переміщуйте фрагмент клавішами зі стрілками, утримуйте Shift для менших кроків. Натисніть Enter, щоб надіслати, Home, щоб повернути його на початок, або Escape, щоб скасувати.",
399
399
  "POSITION": "{{x}} відсотків зліва, {{y}} відсотків згори",
400
400
  "CHECKING": "Перевіряємо вашу відповідь",
401
- "RETRY_ANNOUNCEMENT": "Майже. Завантажено новий пазл, і фрагмент знову на початку."
401
+ "RETRY_ANNOUNCEMENT": "Майже. Завантажено новий пазл, і фрагмент знову на початку.",
402
+ "REFRESH": "Інший пазл"
402
403
  }
403
404
  },
404
405
  "CONTRACT": {
@@ -398,7 +398,8 @@
398
398
  "KEYBOARD_HINT": "Dùng các phím mũi tên để di chuyển mảnh ghép, giữ Shift để đi từng bước nhỏ hơn. Nhấn Enter để gửi, Home để đưa nó về vị trí ban đầu, hoặc Escape để hủy.",
399
399
  "POSITION": "{{x}} phần trăm từ bên trái, {{y}} phần trăm từ trên xuống",
400
400
  "CHECKING": "Đang kiểm tra câu trả lời của bạn",
401
- "RETRY_ANNOUNCEMENT": "Suýt nữa. Một hình ghép mới đã được tải và mảnh ghép trở lại vị trí ban đầu."
401
+ "RETRY_ANNOUNCEMENT": "Suýt nữa. Một hình ghép mới đã được tải và mảnh ghép trở lại vị trí ban đầu.",
402
+ "REFRESH": "Hình ghép khác"
402
403
  }
403
404
  },
404
405
  "CONTRACT": {
@@ -398,7 +398,8 @@
398
398
  "KEYBOARD_HINT": "用方向键移动拼块,按住 Shift 可以小步移动。按 Enter 提交,按 Home 让它回到起点,按 Escape 取消。",
399
399
  "POSITION": "距左边 {{x}} %,距顶部 {{y}} %",
400
400
  "CHECKING": "正在检查您的答案",
401
- "RETRY_ANNOUNCEMENT": "差一点。已加载新的拼图,拼块回到了起点。"
401
+ "RETRY_ANNOUNCEMENT": "差一点。已加载新的拼图,拼块回到了起点。",
402
+ "REFRESH": "换一个拼图"
402
403
  }
403
404
  },
404
405
  "CONTRACT": {
package/package.json CHANGED
@@ -1,67 +1,66 @@
1
1
  {
2
- "name": "@prosopo/locale",
3
- "version": "3.6.2",
4
- "description": "Prosopo locale library",
5
- "type": "module",
6
- "engines": {
7
- "node": "^24",
8
- "npm": "^11"
9
- },
10
- "main": "dist/index.js",
11
- "types": "dist/index.d.ts",
12
- "exports": {
13
- ".": {
14
- "types": "./dist/index.d.ts",
15
- "import": "./dist/index.js",
16
- "require": "./dist/cjs/index.cjs"
17
- },
18
- "./locales/en/translation.json": {
19
- "import": "./dist/locales/en/translation.json",
20
- "require": "./dist/locales/en/translation.json"
21
- }
22
- },
23
- "scripts": {
24
- "clean": "del-cli --verbose dist tsconfig.tsbuildinfo",
25
- "build": "npm run build:cross-env -- --mode ${NODE_ENV:-development}",
26
- "build:cross-env": "vite build --config vite.esm.config.ts",
27
- "build:tsc": "tsc --build --verbose",
28
- "build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV",
29
- "typecheck": "tsc --project tsconfig.types.json",
30
- "test": "NODE_ENV=${NODE_ENV:-test}; npx vitest run --config ./vite.test.config.ts"
31
- },
32
- "author": "Prosopo Limited",
33
- "license": "Apache-2.0",
34
- "dependencies": {
35
- "@prosopo/util": "3.3.13",
36
- "i18next": "26.4.2",
37
- "i18next-fs-backend": "2.6.8",
38
- "i18next-http-middleware": "3.9.9"
39
- },
40
- "devDependencies": {
41
- "@prosopo/config": "3.4.2",
42
- "@types/node": "22.10.2",
43
- "@vitest/coverage-v8": "4.1.11",
44
- "concurrently": "9.0.1",
45
- "del-cli": "6.0.0",
46
- "dotenv": "16.4.5",
47
- "npm-run-all": "4.1.5",
48
- "tslib": "2.7.0",
49
- "tsx": "4.20.3",
50
- "typescript": "5.6.2",
51
- "vite": "8.1.5",
52
- "vitest": "4.1.11"
53
- },
54
- "repository": {
55
- "type": "git",
56
- "url": "git+https://github.com/prosopo/captcha.git",
57
- "directory": "packages/locale"
58
- },
59
- "bugs": {
60
- "url": "https://github.com/prosopo/captcha/issues"
61
- },
62
- "homepage": "https://github.com/prosopo/captcha#readme",
63
- "publishConfig": {
64
- "registry": "https://registry.npmjs.org"
65
- },
66
- "sideEffects": false
67
- }
2
+ "name": "@prosopo/locale",
3
+ "version": "3.7.0",
4
+ "description": "Prosopo locale library",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": "^24"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js",
15
+ "require": "./dist/cjs/index.cjs"
16
+ },
17
+ "./locales/en/translation.json": {
18
+ "import": "./dist/locales/en/translation.json",
19
+ "require": "./dist/locales/en/translation.json"
20
+ }
21
+ },
22
+ "author": "Prosopo Limited",
23
+ "license": "Apache-2.0",
24
+ "dependencies": {
25
+ "@prosopo/util": "3.3.14",
26
+ "i18next": "26.4.2",
27
+ "i18next-fs-backend": "2.6.8",
28
+ "i18next-http-middleware": "3.9.9"
29
+ },
30
+ "devDependencies": {
31
+ "@prosopo/config": "3.4.3",
32
+ "@types/node": "22.10.2",
33
+ "@vitest/coverage-v8": "4.1.11",
34
+ "concurrently": "9.0.1",
35
+ "del-cli": "6.0.0",
36
+ "dotenv": "16.4.5",
37
+ "npm-run-all": "4.1.5",
38
+ "tslib": "2.7.0",
39
+ "tsx": "4.20.3",
40
+ "typescript": "5.6.2",
41
+ "vite": "8.1.5",
42
+ "vitest": "4.1.11"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/prosopo/captcha.git",
47
+ "directory": "packages/locale"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/prosopo/captcha/issues"
51
+ },
52
+ "homepage": "https://github.com/prosopo/captcha#readme",
53
+ "publishConfig": {
54
+ "registry": "https://registry.npmjs.org"
55
+ },
56
+ "sideEffects": false,
57
+ "scripts": {
58
+ "clean": "del-cli --verbose dist tsconfig.tsbuildinfo",
59
+ "build": "pnpm run build:cross-env --mode ${NODE_ENV:-development}",
60
+ "build:cross-env": "vite build --config vite.esm.config.ts",
61
+ "build:tsc": "tsc --build --verbose",
62
+ "build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV",
63
+ "typecheck": "tsc --project tsconfig.types.json",
64
+ "test": "NODE_ENV=${NODE_ENV:-test}; pnpm exec vitest run --config ./vite.test.config.ts"
65
+ }
66
+ }
package/src/direction.ts DELETED
@@ -1,36 +0,0 @@
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
- export type TextDirection = "ltr" | "rtl";
16
-
17
- // Scripts written right to left, by primary language subtag. Wider than the
18
- // shipped catalogues so a site asking for e.g. "he" still gets a mirrored
19
- // layout around whichever fallback text it is served.
20
- const RTL_LANGUAGES: ReadonlySet<string> = new Set([
21
- "ar",
22
- "ckb",
23
- "dv",
24
- "fa",
25
- "he",
26
- "ps",
27
- "sd",
28
- "ug",
29
- "ur",
30
- "yi",
31
- ]);
32
-
33
- export const getLanguageDirection = (language: string): TextDirection => {
34
- const primary = language.split(/[-_]/)[0]?.toLowerCase() ?? "";
35
- return RTL_LANGUAGES.has(primary) ? "rtl" : "ltr";
36
- };
@@ -1,37 +0,0 @@
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 { isTranslationKey } from "./translationKey.js";
15
- import type { Ti18n } from "./types.js";
16
-
17
- export interface KeyedErrorMessage {
18
- message: string;
19
- key?: string;
20
- }
21
-
22
- /**
23
- * The provider translates error messages into the request's Accept-Language,
24
- * which is the browser's language rather than the widget's. Re-translating the
25
- * error key on the client shows the error in the language the widget renders
26
- * in. Free text and keys the catalogue doesn't have keep the server's message.
27
- */
28
- export const localiseErrorMessage = (
29
- i18n: Ti18n | undefined,
30
- error: KeyedErrorMessage,
31
- ): string => {
32
- const { key, message } = error;
33
- if (!key || !isTranslationKey(key) || !i18n?.isInitialized) {
34
- return message;
35
- }
36
- return i18n.t(key, { defaultValue: message });
37
- };
@@ -1,29 +0,0 @@
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 { getProcess } from "./process.js";
15
- import { LanguageCodes, Languages } from "./translations.js";
16
-
17
- // Read through getProcess() so the module stays loadable in a plain browser
18
- // runtime where `process` is undefined. Without that guard, any side-effectful
19
- // runtime import of `@prosopo/types` (which transitively reaches here via
20
- // `LanguageSchema`) crashes the page in Vite's dev/preview mode.
21
- const logLevel = getProcess()?.env?.PROSOPO_LOG_LEVEL;
22
-
23
- export const i18nSharedOptions = {
24
- debug: logLevel === "debug",
25
- fallbackLng: Languages.english,
26
- namespace: "translation",
27
- supportedLngs: LanguageCodes,
28
- nonExplicitSupportedLngs: false,
29
- };
@@ -1,69 +0,0 @@
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 path from "node:path";
16
- import i18n from "i18next";
17
- import FSBackend from "i18next-fs-backend/cjs"; // https://github.com/i18next/i18next-fs-backend/issues/57
18
- import { LanguageDetector as MiddlewareLanguageDetector } from "i18next-http-middleware";
19
- import { i18nSharedOptions } from "./i18SharedOptions.js";
20
- import { isServerSide } from "./util.js";
21
-
22
- const loadPath =
23
- `${path.dirname(import.meta.url)}/locales/{{lng}}/{{ns}}.json`.replace(
24
- "file://",
25
- "",
26
- );
27
-
28
- export function initializeI18n(
29
- i18nLoadedCallback?: (value: typeof i18n) => void,
30
- ) {
31
- if (!i18n.isInitialized && isServerSide()) {
32
- const lngDetector = new MiddlewareLanguageDetector(null, {
33
- order: ["header", "query", "cookie"],
34
- });
35
- i18n
36
- // @ts-ignore https://github.com/i18next/i18next-fs-backend/issues/57
37
- .use(FSBackend)
38
- .use(lngDetector) // this line should switch the language to the one the user reports in the Accept-Language header
39
- .init({
40
- ...i18nSharedOptions,
41
- ns: ["translation"],
42
- backend: {
43
- loadPath,
44
- },
45
- });
46
- // "loaded" fires once per namespace, and again after changeLanguage(),
47
- // so the raw event is not the one-shot signal the callback expects.
48
- // Latch it here as well as in loadI18next: this callback is public and
49
- // a caller could reasonably use it to run a side effect.
50
- let notified = false;
51
- i18n.on("loaded", () => {
52
- if (notified) {
53
- return;
54
- }
55
- notified = true;
56
- i18nLoadedCallback?.(i18n);
57
- });
58
- } else {
59
- // Nothing to initialise — either i18next is already up (a frontend
60
- // consumer got there first) or we are not on the server. The `loaded`
61
- // handler above is only registered inside the init branch, so without
62
- // this the callback would never fire and loadI18next() would stay
63
- // pending forever instead of handing back the instance we already have.
64
- i18nLoadedCallback?.(i18n);
65
- }
66
- return i18n;
67
- }
68
-
69
- export default initializeI18n;
@@ -1,267 +0,0 @@
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 { flatten } from "@prosopo/util";
16
- import { i18nSharedOptions } from "./i18SharedOptions.js";
17
- import type { I18nEvent, Ti18n, TranslateOptions } from "./types.js";
18
-
19
- const NAMESPACE = "translation";
20
- const FALLBACK_LANGUAGE = i18nSharedOptions.fallbackLng;
21
- const STORAGE_KEY = "i18nextLng";
22
- const COOKIE_KEY = "i18next";
23
- const COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
24
-
25
- /**
26
- * How long to wait for a catalogue before rendering untranslated.
27
- *
28
- * A widget with no text is worse than a widget in English: every caller
29
- * supplies a `defaultValue`, and `t()` falls back to the key otherwise, so a
30
- * catalogue that never arrives degrades rather than blocks.
31
- */
32
- const CATALOGUE_FETCH_TIMEOUT_MS = 10_000;
33
-
34
- type Catalogue = Record<string, string>;
35
-
36
- const SUPPORTED_LANGUAGES: readonly string[] = i18nSharedOptions.supportedLngs;
37
-
38
- /**
39
- * Locale JSON sits next to the bundle that asked for it. The widget is embedded
40
- * by third parties and cannot know a base URL, but it does know where its own
41
- * module was served from.
42
- */
43
- const catalogueUrl = (language: string): string => {
44
- const moduleUrl = import.meta.url;
45
- const directory = moduleUrl.slice(0, moduleUrl.lastIndexOf("/"));
46
- return `${directory}/locales/${language}/${NAMESPACE}.json`;
47
- };
48
-
49
- /**
50
- * `en-GB` is not in the catalogue list but `en` is, so a regional tag resolves
51
- * to its base language rather than falling all the way back to English.
52
- */
53
- const resolveLanguage = (candidate: string | undefined): string | undefined => {
54
- if (!candidate) {
55
- return undefined;
56
- }
57
- if (SUPPORTED_LANGUAGES.includes(candidate)) {
58
- return candidate;
59
- }
60
- const base = candidate.split("-")[0];
61
- return base && SUPPORTED_LANGUAGES.includes(base) ? base : undefined;
62
- };
63
-
64
- const readCookie = (): string | undefined => {
65
- try {
66
- const match = document.cookie.match(
67
- new RegExp(`(?:^|; )${COOKIE_KEY}=([^;]*)`),
68
- );
69
- return match?.[1] ? decodeURIComponent(match[1]) : undefined;
70
- } catch {
71
- return undefined;
72
- }
73
- };
74
-
75
- const readStorage = (): string | undefined => {
76
- try {
77
- return localStorage.getItem(STORAGE_KEY) ?? undefined;
78
- } catch {
79
- return undefined;
80
- }
81
- };
82
-
83
- const navigatorLanguages = (): readonly string[] => {
84
- try {
85
- return navigator.languages?.length
86
- ? navigator.languages
87
- : [navigator.language];
88
- } catch {
89
- return [];
90
- }
91
- };
92
-
93
- /**
94
- * Cookie and localStorage are both written, matching what the widget did when
95
- * i18next-browser-languagedetector handled this, so a visitor's choice survives
96
- * across pages that embed the widget. Either store can throw in a sandboxed
97
- * iframe or with third-party storage blocked; neither is load-bearing.
98
- */
99
- const cacheLanguage = (language: string): void => {
100
- try {
101
- localStorage.setItem(STORAGE_KEY, language);
102
- } catch {
103
- // storage unavailable — the language is still active for this page
104
- }
105
- try {
106
- document.cookie = `${COOKIE_KEY}=${encodeURIComponent(language)};max-age=${COOKIE_MAX_AGE_SECONDS};path=/`;
107
- } catch {
108
- // cookies unavailable — as above
109
- }
110
- };
111
-
112
- const detectLanguage = (): string => {
113
- const candidates = [readCookie(), readStorage(), ...navigatorLanguages()];
114
- for (const candidate of candidates) {
115
- const resolved = resolveLanguage(candidate);
116
- if (resolved) {
117
- return resolved;
118
- }
119
- }
120
- return FALLBACK_LANGUAGE;
121
- };
122
-
123
- const fetchCatalogue = async (language: string): Promise<Catalogue> => {
124
- const controller = new AbortController();
125
- const timer = setTimeout(
126
- () => controller.abort(),
127
- CATALOGUE_FETCH_TIMEOUT_MS,
128
- );
129
- try {
130
- const response = await fetch(catalogueUrl(language), {
131
- signal: controller.signal,
132
- });
133
- if (!response.ok) {
134
- return {};
135
- }
136
- const body: unknown = await response.json();
137
- return typeof body === "object" && body !== null ? flatten(body) : {};
138
- } catch {
139
- return {};
140
- } finally {
141
- clearTimeout(timer);
142
- }
143
- };
144
-
145
- const interpolate = (template: string, options?: TranslateOptions): string =>
146
- options
147
- ? template.replace(/\{\{(\w+)\}\}/g, (placeholder, name: string) =>
148
- name in options ? String(options[name]) : placeholder,
149
- )
150
- : template;
151
-
152
- interface BrowserI18n extends Ti18n {
153
- /** Resolves once the active language's catalogue has been fetched. */
154
- ready(): Promise<BrowserI18n>;
155
- }
156
-
157
- const createBrowserI18n = (requested?: string): BrowserI18n => {
158
- const catalogues = new Map<string, Catalogue>();
159
- const listeners = new Map<I18nEvent, Set<() => void>>();
160
- let language = resolveLanguage(requested) ?? detectLanguage();
161
- let initialised = false;
162
-
163
- const emit = (event: I18nEvent): void => {
164
- for (const listener of listeners.get(event) ?? []) {
165
- listener();
166
- }
167
- };
168
-
169
- /**
170
- * The fallback catalogue is fetched alongside the active one so a key
171
- * missing from a translation still renders English rather than its key —
172
- * the same key-level fallback i18next gave us via `fallbackLng`.
173
- */
174
- const loadCatalogues = async (target: string): Promise<void> => {
175
- const wanted = [...new Set([target, FALLBACK_LANGUAGE])].filter(
176
- (candidate) => !catalogues.has(candidate),
177
- );
178
- await Promise.all(
179
- wanted.map(async (candidate) => {
180
- catalogues.set(candidate, await fetchCatalogue(candidate));
181
- }),
182
- );
183
- };
184
-
185
- const lookup = (candidate: string, key: string): string | undefined =>
186
- catalogues.get(candidate)?.[key];
187
-
188
- cacheLanguage(language);
189
- const loading = loadCatalogues(language).then(() => {
190
- initialised = true;
191
- emit("initialized");
192
- emit("loaded");
193
- });
194
-
195
- const instance: BrowserI18n = {
196
- get language(): string {
197
- return language;
198
- },
199
- get isInitialized(): boolean {
200
- return initialised;
201
- },
202
- t: (key: string, options?: TranslateOptions): string =>
203
- interpolate(
204
- lookup(language, key) ??
205
- lookup(FALLBACK_LANGUAGE, key) ??
206
- options?.defaultValue ??
207
- key,
208
- options,
209
- ),
210
- hasLoadedNamespace: (namespace: string): boolean =>
211
- namespace === NAMESPACE && catalogues.has(language),
212
- changeLanguage: async (next: string): Promise<BrowserI18n> => {
213
- const resolved = resolveLanguage(next) ?? FALLBACK_LANGUAGE;
214
- if (resolved !== language) {
215
- language = resolved;
216
- cacheLanguage(resolved);
217
- await loadCatalogues(resolved);
218
- emit("loaded");
219
- emit("languageChanged");
220
- }
221
- return instance;
222
- },
223
- on: (event: I18nEvent, listener: () => void): void => {
224
- const existing = listeners.get(event) ?? new Set<() => void>();
225
- existing.add(listener);
226
- listeners.set(event, existing);
227
- },
228
- off: (event: I18nEvent, listener: () => void): void => {
229
- listeners.get(event)?.delete(listener);
230
- },
231
- ready: async (): Promise<BrowserI18n> => {
232
- await loading;
233
- return instance;
234
- },
235
- };
236
-
237
- return instance;
238
- };
239
-
240
- /**
241
- * The widget owns this instance. A host page running its own i18n must not be
242
- * able to capture the widget's translations, and every widget on the page
243
- * shares one catalogue fetch.
244
- */
245
- let instance: BrowserI18n | undefined;
246
-
247
- export function initializeI18n(
248
- i18nLoadedCallback?: (value: Ti18n) => void,
249
- lng?: string,
250
- ): Ti18n {
251
- if (!instance) {
252
- // Passing `lng` here rather than calling changeLanguage() afterwards is
253
- // what stops the widget rendering a flash of the browser's language
254
- // before the site owner's choice is applied.
255
- const created = createBrowserI18n(lng);
256
- instance = created;
257
- void created.ready().then(() => i18nLoadedCallback?.(created));
258
- } else {
259
- // Already built (a second widget on the page, or createTranslator() got
260
- // here first). The callback is only armed in the branch above, so it has
261
- // to fire here or loadI18next() never settles.
262
- i18nLoadedCallback?.(instance);
263
- }
264
- return instance;
265
- }
266
-
267
- export default initializeI18n;