@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
@@ -0,0 +1,16 @@
1
+ export interface TranslateOptions {
2
+ defaultValue?: string;
3
+ [value: string]: unknown;
4
+ }
5
+ export type TranslateFn = (key: string, options?: TranslateOptions) => string;
6
+ export type I18nEvent = "initialized" | "loaded" | "languageChanged";
7
+ export interface Ti18n {
8
+ readonly language: string;
9
+ readonly isInitialized: boolean;
10
+ t: TranslateFn;
11
+ changeLanguage(language: string): Promise<unknown>;
12
+ hasLoadedNamespace(namespace: string): boolean;
13
+ on(event: I18nEvent, listener: () => void): void;
14
+ off(event: I18nEvent, listener: () => void): void;
15
+ }
16
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAmBA,MAAM,WAAW,gBAAgB;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACzB;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,KAAK,MAAM,CAAC;AAE9E,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,iBAAiB,CAAC;AAOrE,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,CAAC,EAAE,WAAW,CAAC;IACf,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnD,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/C,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;IACjD,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CAClD"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prosopo/locale",
3
- "version": "3.4.3",
3
+ "version": "3.6.0",
4
4
  "description": "Prosopo locale library",
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,18 +32,13 @@
32
32
  "author": "Prosopo Limited",
33
33
  "license": "Apache-2.0",
34
34
  "dependencies": {
35
+ "@prosopo/util": "3.3.11",
35
36
  "i18next": "26.4.2",
36
- "i18next-browser-languagedetector": "8.2.1",
37
- "i18next-chained-backend": "4.6.2",
38
37
  "i18next-fs-backend": "2.6.8",
39
- "i18next-http-backend": "3.0.2",
40
- "i18next-http-middleware": "3.9.9",
41
- "i18next-resources-to-backend": "1.2.3",
42
- "react-i18next": "17.0.13",
43
- "zod": "3.23.8"
38
+ "i18next-http-middleware": "3.9.9"
44
39
  },
45
40
  "devDependencies": {
46
- "@prosopo/config": "3.3.17",
41
+ "@prosopo/config": "3.4.0",
47
42
  "@types/node": "22.10.2",
48
43
  "@vitest/coverage-v8": "4.1.11",
49
44
  "concurrently": "9.0.1",
@@ -12,7 +12,7 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  import { getProcess } from "./process.js";
15
- import { LanguageSchema, Languages } from "./translations.js";
15
+ import { LanguageCodes, Languages } from "./translations.js";
16
16
 
17
17
  // Read through getProcess() so the module stays loadable in a plain browser
18
18
  // runtime where `process` is undefined. Without that guard, any side-effectful
@@ -22,8 +22,8 @@ const logLevel = getProcess()?.env?.PROSOPO_LOG_LEVEL;
22
22
 
23
23
  export const i18nSharedOptions = {
24
24
  debug: logLevel === "debug",
25
- fallbackLng: LanguageSchema.enum.en,
25
+ fallbackLng: Languages.english,
26
26
  namespace: "translation",
27
- supportedLngs: Object.values(Languages),
27
+ supportedLngs: LanguageCodes,
28
28
  nonExplicitSupportedLngs: false,
29
29
  };
@@ -12,94 +12,256 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import i18n, { type InitOptions } from "i18next";
16
- import I18nextBrowserLanguageDetector from "i18next-browser-languagedetector";
17
- import ChainedBackend from "i18next-chained-backend";
18
- import HttpBackend from "i18next-http-backend";
19
- import resourcesToBackend from "i18next-resources-to-backend";
20
- import { initReactI18next } from "react-i18next";
15
+ import { flatten } from "@prosopo/util";
21
16
  import { i18nSharedOptions } from "./i18SharedOptions.js";
17
+ import type { I18nEvent, Ti18n, TranslateOptions } from "./types.js";
22
18
 
23
- const reactOptions: InitOptions = {
24
- react: {
25
- useSuspense: true,
26
- },
27
- detection: {
28
- order: ["navigator", "htmlTag", "path", "subdomain"],
29
- caches: ["localStorage", "cookie"],
30
- },
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`;
31
47
  };
32
48
 
33
- const getLoadPath = (language: string, namespace: string) => {
34
- const filename = import.meta.url.split("/").pop();
35
- return `${import.meta.url.replace(`/${filename}`, "")}/locales/${language}/${namespace}.json`;
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;
36
62
  };
37
63
 
38
- export function initializeI18n(
39
- i18nLoadedCallback?: (value: typeof i18n) => void,
40
- lng?: string,
41
- ) {
42
- if (!i18n.isInitialized) {
43
- i18n
44
- // @ts-ignore
45
- .use(ChainedBackend)
46
- .use(
47
- new I18nextBrowserLanguageDetector(null, {
48
- order: ["cookie", "localStorage", "navigator"],
49
- lookupQuerystring: "lng",
50
- lookupCookie: "i18next",
51
- lookupLocalStorage: "i18nextLng",
52
- caches: ["localStorage", "cookie"],
53
- }),
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,
54
149
  )
55
- .use(initReactI18next)
56
- .init({
57
- ...i18nSharedOptions,
58
- // When the site owner has resolved a language upfront (via widget
59
- // config or data-language) we set `lng` explicitly so i18next skips
60
- // browser detection. This is the single source of truth for the
61
- // widget language and prevents the flash-of-wrong-language race
62
- // where child components read the browser-detected language on
63
- // first render before an effect can call changeLanguage().
64
- ...(lng ? { lng } : {}),
65
- backend: {
66
- backends: [
67
- HttpBackend, // if a namespace can't be loaded via normal http-backend loadPath, then the inMemoryLocalBackend will try to return the correct resources
68
- // with dynamic import, you have to use the "default" key of the module ( https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#importing_defaults )
69
- resourcesToBackend(
70
- (language: string, namespace: string) =>
71
- import(getLoadPath(language, namespace)),
72
- ),
73
- ],
74
- backendOptions: [
75
- {
76
- loadPath: getLoadPath("{{lng}}", "{{ns}}"),
77
- },
78
- ],
79
- },
80
- ...reactOptions,
81
- } as InitOptions);
82
- // "loaded" fires once per namespace, and again after changeLanguage(),
83
- // so the raw event is not the one-shot signal the callback expects.
84
- // Latch it here as well as in loadI18next: this callback is public and
85
- // a caller could reasonably use it to run a side effect.
86
- let notified = false;
87
- i18n.on("loaded", () => {
88
- if (notified) {
89
- return;
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");
90
220
  }
91
- notified = true;
92
- i18nLoadedCallback?.(i18n);
93
- });
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));
94
258
  } else {
95
- // Already initialised (e.g. useTranslation() ran first, or a second
96
- // widget mounted). The `loaded` handler is only registered in the init
97
- // branch, so the callback has to fire here or loadI18next() never
98
- // settles. Resources are already present, so resolving immediately is
99
- // correct; the caller reconciles the language afterwards.
100
- i18nLoadedCallback?.(i18n);
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);
101
263
  }
102
- return i18n;
264
+ return instance;
103
265
  }
104
266
 
105
267
  export default initializeI18n;
@@ -12,6 +12,7 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
+ import i18n from "i18next";
15
16
  import { handle } from "i18next-http-middleware";
16
17
  import loadI18next from "./loadI18next.js";
17
18
 
@@ -23,7 +24,11 @@ type HandleOptions = NonNullable<Parameters<typeof handle>[1]>;
23
24
  async function i18nMiddleware(
24
25
  options: HandleOptions,
25
26
  ): Promise<ReturnType<typeof handle>> {
26
- const i18n = await loadI18next(true);
27
+ // The middleware needs the i18next instance itself, not the narrow Ti18n
28
+ // the widget shares with it. loadI18next(true) initialises that very
29
+ // singleton and waits for its resources, so importing it here is the same
30
+ // object, fully loaded.
31
+ await loadI18next(true);
27
32
  return handle(i18n, { ...options });
28
33
  }
29
34
 
package/src/index.ts CHANGED
@@ -14,9 +14,16 @@
14
14
 
15
15
  export { default as i18nMiddleware } from "./i18nMiddleware.js";
16
16
  export { default as loadI18next } from "./loadI18next.js";
17
- export { Languages, LanguageSchema } from "./translations.js";
17
+ export { isLanguage, LanguageCodes, Languages } from "./translations.js";
18
+ export type { Language } from "./translations.js";
18
19
  export { isClientSide } from "./util.js";
19
- export { TranslationKeysSchema } from "./translationKey.js";
20
+ export { translationKeys } from "./translationKey.js";
20
21
  export type { TranslationKey } from "./translationKey.js";
21
- export { default as useTranslation } from "./useTranslation.js";
22
- export type { Ti18n } from "./loadI18next.js";
22
+ export { createTranslator } from "./translator.js";
23
+ export type { Translator } from "./translator.js";
24
+ export type {
25
+ I18nEvent,
26
+ Ti18n,
27
+ TranslateFn,
28
+ TranslateOptions,
29
+ } from "./types.js";
@@ -12,8 +12,9 @@
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
- let i18nInstance: i18n;
15
+ import type { Ti18n } from "./types.js";
16
+
17
+ let i18nInstance: Ti18n;
17
18
 
18
19
  /**
19
20
  * How long to wait for i18next to report `loaded` before giving up on it.
@@ -28,16 +29,16 @@ let i18nInstance: i18n;
28
29
  export const I18N_LOAD_TIMEOUT_MS = 10_000;
29
30
 
30
31
  const reconcileLanguage = async (
31
- instance: i18n,
32
+ instance: Ti18n,
32
33
  lng: string | undefined,
33
- ): Promise<i18n> => {
34
+ ): Promise<Ti18n> => {
34
35
  if (lng && instance.language !== lng) {
35
36
  await instance.changeLanguage(lng);
36
37
  }
37
38
  return instance;
38
39
  };
39
40
 
40
- async function loadI18next(backend: boolean, lng?: string): Promise<i18n> {
41
+ async function loadI18next(backend: boolean, lng?: string): Promise<Ti18n> {
41
42
  return new Promise((resolve, reject) => {
42
43
  // On timeout we resolve with the (possibly not fully loaded) instance
43
44
  // rather than rejecting. i18next falls back to the key itself when a
@@ -69,7 +70,7 @@ async function loadI18next(backend: boolean, lng?: string): Promise<i18n> {
69
70
  // Both latches are one-shot: i18next can emit `loaded` more than once
70
71
  // (per namespace, and again after changeLanguage), and a late event
71
72
  // arriving after the timeout must not re-settle or leak a second timer.
72
- const done = (instance: i18n): void => {
73
+ const done = (instance: Ti18n): void => {
73
74
  if (settled) {
74
75
  return;
75
76
  }
@@ -118,7 +119,7 @@ async function loadI18next(backend: boolean, lng?: string): Promise<i18n> {
118
119
  }, lng);
119
120
  } else {
120
121
  // Singleton already exists (e.g. a prior widget mounted with a
121
- // different language, or a React consumer initialised earlier).
122
+ // different language, or a direct consumer initialised earlier).
122
123
  // Reconcile before resolving so callers can render synchronously
123
124
  // against the requested language instead of seeing a flash of
124
125
  // the previous one.
@@ -132,6 +133,4 @@ async function loadI18next(backend: boolean, lng?: string): Promise<i18n> {
132
133
  });
133
134
  }
134
135
 
135
- export type { i18n as Ti18n };
136
-
137
136
  export default loadI18next;
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "كثرة الطلبات من عنوان البريد الإلكتروني هذا",
48
48
  "VPN_BLOCKED": "لا يُسمح بالطلبات من اتصالات VPN لهذا الموقع",
49
49
  "PROXY_BLOCKED": "لا يُسمح بالطلبات من خوادم البروكسي لهذا الموقع",
50
+ "TOO_MANY_LOCALHOST": "عدد كبير جدًا من طلبات CAPTCHA من localhost لمفتاح الموقع هذا",
50
51
  "TOR_BLOCKED": "لا يُسمح بالطلبات من عقد خروج Tor لهذا الموقع",
51
52
  "ABUSER_BLOCKED": "لا يُسمح بالطلبات من الشبكات المُبلَّغ عنها بسبب إساءة الاستخدام لهذا الموقع",
52
53
  "DATACENTER_BLOCKED": "لا يُسمح بالطلبات من عناوين IP لمراكز البيانات لهذا الموقع",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Bu e-poçt ünvanından çox sayda göndəriş",
48
48
  "VPN_BLOCKED": "Bu sayt üçün VPN bağlantılarından sorğulara icazə verilmir",
49
49
  "PROXY_BLOCKED": "Bu sayt üçün proksi serverlərdən sorğulara icazə verilmir",
50
+ "TOO_MANY_LOCALHOST": "Bu sayt açarı üçün localhost-dan həddindən artıq çox CAPTCHA sorğusu",
50
51
  "TOR_BLOCKED": "Bu sayt üçün Tor çıxış qovşaqlarından sorğulara icazə verilmir",
51
52
  "ABUSER_BLOCKED": "Bu sayt üçün sui-istifadə üçün işarələnmiş şəbəkələrdən sorğulara icazə verilmir",
52
53
  "DATACENTER_BLOCKED": "Bu sayt üçün məlumat mərkəzi IP ünvanlarından sorğulara icazə verilmir",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Příliš mnoho odeslání z této e-mailové adresy",
48
48
  "VPN_BLOCKED": "Požadavky z VPN připojení nejsou pro tento web povoleny",
49
49
  "PROXY_BLOCKED": "Požadavky z proxy serverů nejsou pro tento web povoleny",
50
+ "TOO_MANY_LOCALHOST": "Příliš mnoho požadavků CAPTCHA z localhost pro tento klíč webu",
50
51
  "TOR_BLOCKED": "Požadavky z výstupních uzlů Tor nejsou pro tento web povoleny",
51
52
  "ABUSER_BLOCKED": "Požadavky ze sítí označených za zneužívající nejsou pro tento web povoleny",
52
53
  "DATACENTER_BLOCKED": "Požadavky z IP adres datových center nejsou pro tento web povoleny",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Zu viele Einsendungen von dieser E-Mail-Adresse",
48
48
  "VPN_BLOCKED": "Anfragen über VPN-Verbindungen sind für diese Website nicht erlaubt",
49
49
  "PROXY_BLOCKED": "Anfragen über Proxy-Server sind für diese Website nicht erlaubt",
50
+ "TOO_MANY_LOCALHOST": "Zu viele CAPTCHA-Anfragen von localhost für diesen Site-Key",
50
51
  "TOR_BLOCKED": "Anfragen über Tor-Exit-Knoten sind für diese Website nicht erlaubt",
51
52
  "ABUSER_BLOCKED": "Anfragen von als missbräuchlich markierten Netzwerken sind für diese Website nicht erlaubt",
52
53
  "DATACENTER_BLOCKED": "Anfragen von Rechenzentrum-IP-Adressen sind für diese Website nicht erlaubt",
@@ -34,6 +34,7 @@
34
34
  "SPAM_EMAIL_COUNT_EXCEEDED": "Πάρα πολλές υποβολές από αυτή τη διεύθυνση email",
35
35
  "VPN_BLOCKED": "Αιτήματα από συνδέσεις VPN δεν επιτρέπονται για αυτόν τον ιστότοπο",
36
36
  "PROXY_BLOCKED": "Αιτήματα από διακομιστές proxy δεν επιτρέπονται για αυτόν τον ιστότοπο",
37
+ "TOO_MANY_LOCALHOST": "Πάρα πολλά αιτήματα CAPTCHA από το localhost για αυτό το κλειδί ιστότοπου",
37
38
  "TOR_BLOCKED": "Αιτήματα από κόμβους εξόδου Tor δεν επιτρέπονται για αυτόν τον ιστότοπο",
38
39
  "ABUSER_BLOCKED": "Αιτήματα από δίκτυα που έχουν επισημανθεί για κατάχρηση δεν επιτρέπονται για αυτόν τον ιστότοπο",
39
40
  "DATACENTER_BLOCKED": "Αιτήματα από διευθύνσεις IP κέντρων δεδομένων δεν επιτρέπονται για αυτόν τον ιστότοπο",
@@ -139,6 +139,7 @@
139
139
  "SPAM_EMAIL_COUNT_EXCEEDED": "Too many submissions from this email address",
140
140
  "VPN_BLOCKED": "Requests from VPN connections are not allowed for this site",
141
141
  "PROXY_BLOCKED": "Requests from proxy servers are not allowed for this site",
142
+ "TOO_MANY_LOCALHOST": "Too many CAPTCHA requests from localhost for this site key",
142
143
  "TOR_BLOCKED": "Requests from Tor exit nodes are not allowed for this site",
143
144
  "ABUSER_BLOCKED": "Requests from networks flagged for abuse are not allowed for this site",
144
145
  "DATACENTER_BLOCKED": "Requests from datacenter IP addresses are not allowed for this site",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Demasiados envíos desde esta dirección de correo electrónico",
48
48
  "VPN_BLOCKED": "Las solicitudes desde conexiones VPN no están permitidas para este sitio",
49
49
  "PROXY_BLOCKED": "Las solicitudes desde servidores proxy no están permitidas para este sitio",
50
+ "TOO_MANY_LOCALHOST": "Demasiadas solicitudes de CAPTCHA desde localhost para esta clave de sitio",
50
51
  "TOR_BLOCKED": "Las solicitudes desde nodos de salida Tor no están permitidas para este sitio",
51
52
  "ABUSER_BLOCKED": "Las solicitudes desde redes marcadas por abuso no están permitidas para este sitio",
52
53
  "DATACENTER_BLOCKED": "Las solicitudes desde direcciones IP de centros de datos no están permitidas para este sitio",
@@ -53,6 +53,7 @@
53
53
  "SPAM_EMAIL_COUNT_EXCEEDED": "Liian monta lähetystä tästä sähköpostiosoitteesta",
54
54
  "VPN_BLOCKED": "VPN-yhteyksien pyynnöt eivät ole sallittuja tälle sivustolle",
55
55
  "PROXY_BLOCKED": "Välityspalvelimien pyynnöt eivät ole sallittuja tälle sivustolle",
56
+ "TOO_MANY_LOCALHOST": "Liian monta CAPTCHA-pyyntöä localhostista tälle sivustoavaimelle",
56
57
  "TOR_BLOCKED": "Tor-poistumissolmujen pyynnöt eivät ole sallittuja tälle sivustolle",
57
58
  "ABUSER_BLOCKED": "Väärinkäytöstä merkittyjen verkkojen pyynnöt eivät ole sallittuja tälle sivustolle",
58
59
  "DATACENTER_BLOCKED": "Datakeskusten IP-osoitteiden pyynnöt eivät ole sallittuja tälle sivustolle",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Trop de soumissions depuis cette adresse e-mail",
48
48
  "VPN_BLOCKED": "Les requêtes provenant de connexions VPN ne sont pas autorisées pour ce site",
49
49
  "PROXY_BLOCKED": "Les requêtes provenant de serveurs proxy ne sont pas autorisées pour ce site",
50
+ "TOO_MANY_LOCALHOST": "Trop de requêtes CAPTCHA provenant de localhost pour cette clé de site",
50
51
  "TOR_BLOCKED": "Les requêtes provenant de nœuds de sortie Tor ne sont pas autorisées pour ce site",
51
52
  "ABUSER_BLOCKED": "Les requêtes provenant de réseaux signalés pour abus ne sont pas autorisées pour ce site",
52
53
  "DATACENTER_BLOCKED": "Les requêtes provenant d'adresses IP de centres de données ne sont pas autorisées pour ce site",
@@ -346,6 +346,7 @@
346
346
  "SPAM_EMAIL_COUNT_EXCEEDED": "इस ईमेल पते से बहुत अधिक सबमिशन",
347
347
  "VPN_BLOCKED": "इस साइट के लिए VPN कनेक्शन से अनुरोध की अनुमति नहीं है",
348
348
  "PROXY_BLOCKED": "इस साइट के लिए प्रॉक्सी सर्वर से अनुरोध की अनुमति नहीं है",
349
+ "TOO_MANY_LOCALHOST": "इस साइट कुंजी के लिए localhost से बहुत अधिक CAPTCHA अनुरोध",
349
350
  "TOR_BLOCKED": "इस साइट के लिए Tor एक्ज़िट नोड्स से अनुरोध की अनुमति नहीं है",
350
351
  "ABUSER_BLOCKED": "दुरुपयोग के लिए चिह्नित नेटवर्क से अनुरोध इस साइट के लिए अनुमत नहीं हैं",
351
352
  "DATACENTER_BLOCKED": "इस साइट के लिए डेटासेंटर IP पतों से अनुरोध की अनुमति नहीं है",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Túl sok beküldés erről az e-mail címről",
48
48
  "VPN_BLOCKED": "VPN-kapcsolatokról érkező kérések nem engedélyezettek ezen az oldalon",
49
49
  "PROXY_BLOCKED": "Proxy szerverekről érkező kérések nem engedélyezettek ezen az oldalon",
50
+ "TOO_MANY_LOCALHOST": "Túl sok CAPTCHA-kérés a localhostról ehhez a webhelykulcshoz",
50
51
  "TOR_BLOCKED": "Tor kilépési csomópontokról érkező kérések nem engedélyezettek ezen az oldalon",
51
52
  "ABUSER_BLOCKED": "Visszaélésért megjelölt hálózatokról érkező kérések nem engedélyezettek ezen az oldalon",
52
53
  "DATACENTER_BLOCKED": "Adatközponti IP-címekről érkező kérések nem engedélyezettek ezen az oldalon",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Terlalu banyak pengiriman dari alamat email ini",
48
48
  "VPN_BLOCKED": "Permintaan dari koneksi VPN tidak diizinkan untuk situs ini",
49
49
  "PROXY_BLOCKED": "Permintaan dari server proxy tidak diizinkan untuk situs ini",
50
+ "TOO_MANY_LOCALHOST": "Terlalu banyak permintaan CAPTCHA dari localhost untuk kunci situs ini",
50
51
  "TOR_BLOCKED": "Permintaan dari node keluar Tor tidak diizinkan untuk situs ini",
51
52
  "ABUSER_BLOCKED": "Permintaan dari jaringan yang ditandai karena penyalahgunaan tidak diizinkan untuk situs ini",
52
53
  "DATACENTER_BLOCKED": "Permintaan dari alamat IP pusat data tidak diizinkan untuk situs ini",
@@ -47,6 +47,7 @@
47
47
  "SPAM_EMAIL_COUNT_EXCEEDED": "Troppi invii da questo indirizzo email",
48
48
  "VPN_BLOCKED": "Le richieste da connessioni VPN non sono consentite per questo sito",
49
49
  "PROXY_BLOCKED": "Le richieste da server proxy non sono consentite per questo sito",
50
+ "TOO_MANY_LOCALHOST": "Troppe richieste CAPTCHA da localhost per questa chiave del sito",
50
51
  "TOR_BLOCKED": "Le richieste da nodi di uscita Tor non sono consentite per questo sito",
51
52
  "ABUSER_BLOCKED": "Le richieste da reti segnalate per abuso non sono consentite per questo sito",
52
53
  "DATACENTER_BLOCKED": "Le richieste da indirizzi IP di data center non sono consentite per questo sito",