@prosopo/locale 3.6.1 → 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 (144) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +8 -9
  2. package/.turbo/turbo-build$colon$tsc.log +6 -9
  3. package/.turbo/turbo-build.log +12 -16
  4. package/CHANGELOG.md +40 -0
  5. package/LICENSE +201 -0
  6. package/dist/cjs/direction.cjs +19 -0
  7. package/dist/cjs/errorMessage.cjs +15 -0
  8. package/dist/cjs/index.cjs +5 -0
  9. package/dist/cjs/locales/en/translation.cjs +23 -5
  10. package/dist/cjs/translationKey.cjs +4 -0
  11. package/dist/direction.d.ts +3 -0
  12. package/dist/direction.d.ts.map +1 -0
  13. package/dist/direction.js +19 -0
  14. package/dist/direction.js.map +1 -0
  15. package/dist/errorMessage.d.ts +7 -0
  16. package/dist/errorMessage.d.ts.map +1 -0
  17. package/dist/errorMessage.js +15 -0
  18. package/dist/errorMessage.js.map +1 -0
  19. package/dist/index.d.ts +5 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +4 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/locales/ar/translation.json +23 -5
  24. package/dist/locales/az/translation.json +23 -5
  25. package/dist/locales/cs/translation.json +23 -5
  26. package/dist/locales/de/translation.json +23 -5
  27. package/dist/locales/el/translation.json +23 -5
  28. package/dist/locales/en/translation.js +23 -5
  29. package/dist/locales/en/translation.json +23 -5
  30. package/dist/locales/es/translation.json +23 -5
  31. package/dist/locales/fi/translation.json +23 -5
  32. package/dist/locales/fr/translation.json +23 -5
  33. package/dist/locales/hi/translation.json +23 -5
  34. package/dist/locales/hu/translation.json +23 -5
  35. package/dist/locales/id/translation.json +23 -5
  36. package/dist/locales/it/translation.json +23 -5
  37. package/dist/locales/ja/translation.json +23 -5
  38. package/dist/locales/jv/translation.json +23 -5
  39. package/dist/locales/ko/translation.json +23 -5
  40. package/dist/locales/ml/translation.json +23 -5
  41. package/dist/locales/ms/translation.json +23 -5
  42. package/dist/locales/nl/translation.json +23 -5
  43. package/dist/locales/no/translation.json +23 -5
  44. package/dist/locales/pl/translation.json +23 -5
  45. package/dist/locales/pt/translation.json +23 -5
  46. package/dist/locales/pt-BR/translation.json +23 -5
  47. package/dist/locales/ro/translation.json +23 -5
  48. package/dist/locales/ru/translation.json +23 -5
  49. package/dist/locales/sr/translation.json +23 -5
  50. package/dist/locales/sv/translation.json +23 -5
  51. package/dist/locales/th/translation.json +23 -5
  52. package/dist/locales/tr/translation.json +23 -5
  53. package/dist/locales/uk/translation.json +23 -5
  54. package/dist/locales/vi/translation.json +23 -5
  55. package/dist/locales/zh-CN/translation.json +23 -5
  56. package/dist/tests/direction.unit.test.d.ts +2 -0
  57. package/dist/tests/direction.unit.test.d.ts.map +1 -0
  58. package/dist/tests/direction.unit.test.js +31 -0
  59. package/dist/tests/direction.unit.test.js.map +1 -0
  60. package/dist/tests/errorMessage.unit.test.d.ts +2 -0
  61. package/dist/tests/errorMessage.unit.test.d.ts.map +1 -0
  62. package/dist/tests/errorMessage.unit.test.js +49 -0
  63. package/dist/tests/errorMessage.unit.test.js.map +1 -0
  64. package/dist/tests/i18nFrontend.unit.test.js +22 -11
  65. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  66. package/dist/tests/locale.test-d.js +18 -3
  67. package/dist/tests/locale.test-d.js.map +1 -1
  68. package/dist/tests/thrownErrorKeys.unit.test.d.ts +2 -0
  69. package/dist/tests/thrownErrorKeys.unit.test.d.ts.map +1 -0
  70. package/dist/tests/thrownErrorKeys.unit.test.js +59 -0
  71. package/dist/tests/thrownErrorKeys.unit.test.js.map +1 -0
  72. package/dist/tests/translationKey.unit.test.js +13 -1
  73. package/dist/tests/translationKey.unit.test.js.map +1 -1
  74. package/dist/translationKey.d.ts +7 -1
  75. package/dist/translationKey.d.ts.map +1 -1
  76. package/dist/translationKey.js +4 -1
  77. package/dist/translationKey.js.map +1 -1
  78. package/dist/translator.d.ts +2 -1
  79. package/dist/translator.d.ts.map +1 -1
  80. package/dist/translator.js.map +1 -1
  81. package/dist/types.d.ts +2 -1
  82. package/dist/types.d.ts.map +1 -1
  83. package/package.json +65 -66
  84. package/src/i18SharedOptions.ts +0 -29
  85. package/src/i18nBackend.ts +0 -69
  86. package/src/i18nFrontend.ts +0 -267
  87. package/src/i18nMiddleware.ts +0 -35
  88. package/src/index.ts +0 -29
  89. package/src/loadI18next.ts +0 -136
  90. package/src/locales/ar/translation.json +0 -476
  91. package/src/locales/az/translation.json +0 -476
  92. package/src/locales/cs/translation.json +0 -476
  93. package/src/locales/de/translation.json +0 -476
  94. package/src/locales/el/translation.json +0 -476
  95. package/src/locales/en/translation.json +0 -476
  96. package/src/locales/es/translation.json +0 -476
  97. package/src/locales/fi/translation.json +0 -476
  98. package/src/locales/fr/translation.json +0 -476
  99. package/src/locales/hi/translation.json +0 -476
  100. package/src/locales/hu/translation.json +0 -476
  101. package/src/locales/id/translation.json +0 -476
  102. package/src/locales/it/translation.json +0 -476
  103. package/src/locales/ja/translation.json +0 -476
  104. package/src/locales/jv/translation.json +0 -476
  105. package/src/locales/ko/translation.json +0 -476
  106. package/src/locales/ml/translation.json +0 -476
  107. package/src/locales/ms/translation.json +0 -476
  108. package/src/locales/nl/translation.json +0 -476
  109. package/src/locales/no/translation.json +0 -476
  110. package/src/locales/pl/translation.json +0 -476
  111. package/src/locales/pt/translation.json +0 -476
  112. package/src/locales/pt-BR/translation.json +0 -476
  113. package/src/locales/ro/translation.json +0 -476
  114. package/src/locales/ru/translation.json +0 -476
  115. package/src/locales/sr/translation.json +0 -476
  116. package/src/locales/sv/translation.json +0 -476
  117. package/src/locales/th/translation.json +0 -476
  118. package/src/locales/tr/translation.json +0 -476
  119. package/src/locales/uk/translation.json +0 -476
  120. package/src/locales/vi/translation.json +0 -476
  121. package/src/locales/zh-CN/translation.json +0 -476
  122. package/src/process.ts +0 -29
  123. package/src/tests/i18SharedOptions.unit.test.ts +0 -139
  124. package/src/tests/i18nBackend.unit.test.ts +0 -241
  125. package/src/tests/i18nFrontend.unit.test.ts +0 -393
  126. package/src/tests/i18nMiddleware.unit.test.ts +0 -159
  127. package/src/tests/loadI18next.unit.test.ts +0 -443
  128. package/src/tests/locale.test-d.ts +0 -160
  129. package/src/tests/localePlaceholders.unit.test.ts +0 -81
  130. package/src/tests/locales.unit.test.ts +0 -79
  131. package/src/tests/process.unit.test.ts +0 -33
  132. package/src/tests/translationKey.unit.test.ts +0 -140
  133. package/src/tests/translations.unit.test.ts +0 -97
  134. package/src/tests/translator.unit.test.ts +0 -213
  135. package/src/tests/util.unit.test.ts +0 -102
  136. package/src/translationKey.ts +0 -64
  137. package/src/translations.ts +0 -55
  138. package/src/translator.ts +0 -68
  139. package/src/types.ts +0 -42
  140. package/src/util.ts +0 -25
  141. package/tsconfig.cjs.json +0 -21
  142. package/tsconfig.json +0 -22
  143. package/tsconfig.tsbuildinfo +0 -1
  144. package/tsconfig.types.json +0 -9
@@ -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;
@@ -1,35 +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 i18n from "i18next";
16
- import { handle } from "i18next-http-middleware";
17
- import loadI18next from "./loadI18next.js";
18
-
19
- // i18next-http-middleware stopped exporting HandleOptions in 3.9.x (the
20
- // interface is still there, just no longer `export`ed). Derive it from handle()
21
- // so it keeps tracking whatever the library actually accepts.
22
- type HandleOptions = NonNullable<Parameters<typeof handle>[1]>;
23
-
24
- async function i18nMiddleware(
25
- options: HandleOptions,
26
- ): Promise<ReturnType<typeof handle>> {
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);
32
- return handle(i18n, { ...options });
33
- }
34
-
35
- export default i18nMiddleware;
package/src/index.ts DELETED
@@ -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
-
15
- export { default as i18nMiddleware } from "./i18nMiddleware.js";
16
- export { default as loadI18next } from "./loadI18next.js";
17
- export { isLanguage, LanguageCodes, Languages } from "./translations.js";
18
- export type { Language } from "./translations.js";
19
- export { isClientSide } from "./util.js";
20
- export { translationKeys } from "./translationKey.js";
21
- export type { TranslationKey } from "./translationKey.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";
@@ -1,136 +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 type { Ti18n } from "./types.js";
16
-
17
- let i18nInstance: Ti18n;
18
-
19
- /**
20
- * How long to wait for i18next to report `loaded` before giving up on it.
21
- *
22
- * Resolution is driven by an event, and events are not guaranteed: a backend
23
- * that never answers (http backend against a dead CDN, fs backend against a
24
- * missing locales dir) leaves the listener armed and the promise pending for
25
- * the lifetime of the process. `cli.ts` calls loadI18next(true).then(...) with
26
- * nothing to time it out, so a pending promise there is a silent boot hang with
27
- * no log line to explain it.
28
- */
29
- export const I18N_LOAD_TIMEOUT_MS = 10_000;
30
-
31
- const reconcileLanguage = async (
32
- instance: Ti18n,
33
- lng: string | undefined,
34
- ): Promise<Ti18n> => {
35
- if (lng && instance.language !== lng) {
36
- await instance.changeLanguage(lng);
37
- }
38
- return instance;
39
- };
40
-
41
- async function loadI18next(backend: boolean, lng?: string): Promise<Ti18n> {
42
- return new Promise((resolve, reject) => {
43
- // On timeout we resolve with the (possibly not fully loaded) instance
44
- // rather than rejecting. i18next falls back to the key itself when a
45
- // resource is missing, so a degraded instance renders untranslated text;
46
- // a rejection instead takes down the caller, and neither caller handles
47
- // one — cli.ts has no .catch, and widgetFactory awaits it inside the
48
- // render path, so the widget would not appear at all. Untranslated beats
49
- // absent. Genuine errors still reject: this arm only fires on silence.
50
- let settled = false;
51
- // No `settled` guard needed here: both latches clear this timer, so it
52
- // can only fire while nothing has settled.
53
- const timer = setTimeout(() => {
54
- settled = true;
55
- if (i18nInstance) {
56
- resolve(i18nInstance);
57
- } else {
58
- reject(
59
- new Error(
60
- `i18next did not load within ${I18N_LOAD_TIMEOUT_MS}ms and no instance was created`,
61
- ),
62
- );
63
- }
64
- }, I18N_LOAD_TIMEOUT_MS);
65
- // Node keeps the event loop alive for a pending timer, which would hold
66
- // a short-lived CLI process open for the full timeout after it had
67
- // otherwise finished.
68
- timer.unref?.();
69
-
70
- // Both latches are one-shot: i18next can emit `loaded` more than once
71
- // (per namespace, and again after changeLanguage), and a late event
72
- // arriving after the timeout must not re-settle or leak a second timer.
73
- const done = (instance: Ti18n): void => {
74
- if (settled) {
75
- return;
76
- }
77
- settled = true;
78
- clearTimeout(timer);
79
- resolve(instance);
80
- };
81
- const failWith = (error: unknown): void => {
82
- if (settled) {
83
- return;
84
- }
85
- settled = true;
86
- clearTimeout(timer);
87
- reject(error);
88
- };
89
-
90
- // Every asynchronous branch below terminates in `.catch(failWith)`. A
91
- // synchronous try/catch cannot see a rejected dynamic import or a
92
- // rejected changeLanguage(), so without these the executor would
93
- // simply return and the promise would stay pending forever — callers
94
- // (i18nMiddleware, and through it the whole server bootstrap) would
95
- // hang rather than fail.
96
- if (backend) {
97
- import("./i18nBackend.js")
98
- .then(({ default: initializeI18n }) => {
99
- if (!i18nInstance) {
100
- // pass the resolver into the i18n init fn, which fires it on the first
101
- // "loaded" event
102
- i18nInstance = initializeI18n(done);
103
- } else {
104
- // we've already initialised i18n so just return it
105
- done(i18nInstance);
106
- }
107
- })
108
- .catch(failWith);
109
- } else {
110
- import("./i18nFrontend.js")
111
- .then(({ default: initializeI18n }) => {
112
- if (!i18nInstance) {
113
- // Pass `lng` in on first init so browser detection is skipped
114
- // entirely when the site owner has supplied a language. The
115
- // resolver only fires on the `loaded` event, so at resolve
116
- // time the target-language resources are guaranteed present.
117
- i18nInstance = initializeI18n((instance) => {
118
- void reconcileLanguage(instance, lng).then(done).catch(failWith);
119
- }, lng);
120
- } else {
121
- // Singleton already exists (e.g. a prior widget mounted with a
122
- // different language, or a direct consumer initialised earlier).
123
- // Reconcile before resolving so callers can render synchronously
124
- // against the requested language instead of seeing a flash of
125
- // the previous one.
126
- void reconcileLanguage(i18nInstance, lng)
127
- .then(done)
128
- .catch(failWith);
129
- }
130
- })
131
- .catch(failWith);
132
- }
133
- });
134
- }
135
-
136
- export default loadI18next;