@prosopo/locale 3.5.0 → 3.6.1

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 (87) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +14 -12
  2. package/.turbo/turbo-build$colon$tsc.log +8 -5
  3. package/.turbo/turbo-build.log +16 -14
  4. package/CHANGELOG.md +54 -0
  5. package/dist/cjs/i18SharedOptions.cjs +2 -2
  6. package/dist/cjs/i18nBackend.cjs +1 -1
  7. package/dist/cjs/i18nFrontend.cjs +169 -51
  8. package/dist/cjs/i18nMiddleware.cjs +5 -2
  9. package/dist/cjs/index.cjs +3 -2
  10. package/dist/cjs/translationKey.cjs +2 -3
  11. package/dist/cjs/translations.cjs +4 -5
  12. package/dist/cjs/translator.cjs +2 -3
  13. package/dist/i18SharedOptions.d.ts +2 -2
  14. package/dist/i18SharedOptions.js +3 -3
  15. package/dist/i18SharedOptions.js.map +1 -1
  16. package/dist/i18nBackend.js +1 -1
  17. package/dist/i18nFrontend.d.ts +2 -2
  18. package/dist/i18nFrontend.d.ts.map +1 -1
  19. package/dist/i18nFrontend.js +169 -45
  20. package/dist/i18nFrontend.js.map +1 -1
  21. package/dist/i18nMiddleware.d.ts.map +1 -1
  22. package/dist/i18nMiddleware.js +3 -1
  23. package/dist/i18nMiddleware.js.map +1 -1
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -3
  27. package/dist/index.js.map +1 -1
  28. package/dist/loadI18next.d.ts +2 -3
  29. package/dist/loadI18next.d.ts.map +1 -1
  30. package/dist/loadI18next.js.map +1 -1
  31. package/dist/locales/fi/translation.json +1 -1
  32. package/dist/locales/hi/translation.json +406 -406
  33. package/dist/locales/nl/translation.json +1 -1
  34. package/dist/tests/i18nFrontend.unit.test.js +218 -170
  35. package/dist/tests/i18nFrontend.unit.test.js.map +1 -1
  36. package/dist/tests/i18nMiddleware.unit.test.js +11 -2
  37. package/dist/tests/i18nMiddleware.unit.test.js.map +1 -1
  38. package/dist/tests/loadI18next.unit.test.js.map +1 -1
  39. package/dist/tests/locale.test-d.js +15 -11
  40. package/dist/tests/locale.test-d.js.map +1 -1
  41. package/dist/tests/localePlaceholders.unit.test.d.ts +2 -0
  42. package/dist/tests/localePlaceholders.unit.test.d.ts.map +1 -0
  43. package/dist/tests/localePlaceholders.unit.test.js +35 -0
  44. package/dist/tests/localePlaceholders.unit.test.js.map +1 -0
  45. package/dist/tests/translationKey.unit.test.js +11 -33
  46. package/dist/tests/translationKey.unit.test.js.map +1 -1
  47. package/dist/tests/translations.unit.test.js +12 -22
  48. package/dist/tests/translations.unit.test.js.map +1 -1
  49. package/dist/translationKey.d.ts +2 -3
  50. package/dist/translationKey.d.ts.map +1 -1
  51. package/dist/translationKey.js +2 -3
  52. package/dist/translationKey.js.map +1 -1
  53. package/dist/translations.d.ts +3 -2
  54. package/dist/translations.d.ts.map +1 -1
  55. package/dist/translations.js +3 -3
  56. package/dist/translations.js.map +1 -1
  57. package/dist/translator.d.ts +2 -3
  58. package/dist/translator.d.ts.map +1 -1
  59. package/dist/translator.js +2 -3
  60. package/dist/translator.js.map +1 -1
  61. package/dist/types.d.ts +16 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +2 -0
  64. package/dist/types.js.map +1 -0
  65. package/package.json +4 -8
  66. package/src/i18SharedOptions.ts +3 -3
  67. package/src/i18nFrontend.ts +240 -73
  68. package/src/i18nMiddleware.ts +6 -1
  69. package/src/index.ts +9 -3
  70. package/src/loadI18next.ts +7 -8
  71. package/src/locales/fi/translation.json +1 -1
  72. package/src/locales/hi/translation.json +406 -406
  73. package/src/locales/nl/translation.json +1 -1
  74. package/src/tests/i18nFrontend.unit.test.ts +310 -216
  75. package/src/tests/i18nMiddleware.unit.test.ts +21 -2
  76. package/src/tests/loadI18next.unit.test.ts +11 -11
  77. package/src/tests/locale.test-d.ts +24 -23
  78. package/src/tests/localePlaceholders.unit.test.ts +81 -0
  79. package/src/tests/translationKey.unit.test.ts +12 -38
  80. package/src/tests/translations.unit.test.ts +18 -31
  81. package/src/translationKey.ts +10 -9
  82. package/src/translations.ts +6 -5
  83. package/src/translator.ts +6 -7
  84. package/src/types.ts +42 -0
  85. package/tsconfig.cjs.json +3 -0
  86. package/tsconfig.json +3 -0
  87. package/tsconfig.tsbuildinfo +1 -1
@@ -12,89 +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";
15
+ import { flatten } from "@prosopo/util";
20
16
  import { i18nSharedOptions } from "./i18SharedOptions.js";
17
+ import type { I18nEvent, Ti18n, TranslateOptions } from "./types.js";
21
18
 
22
- const detectionOptions: InitOptions = {
23
- detection: {
24
- order: ["navigator", "htmlTag", "path", "subdomain"],
25
- caches: ["localStorage", "cookie"],
26
- },
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`;
27
47
  };
28
48
 
29
- const getLoadPath = (language: string, namespace: string) => {
30
- const filename = import.meta.url.split("/").pop();
31
- 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;
32
62
  };
33
63
 
34
- export function initializeI18n(
35
- i18nLoadedCallback?: (value: typeof i18n) => void,
36
- lng?: string,
37
- ) {
38
- if (!i18n.isInitialized) {
39
- i18n
40
- // @ts-ignore
41
- .use(ChainedBackend)
42
- .use(
43
- new I18nextBrowserLanguageDetector(null, {
44
- order: ["cookie", "localStorage", "navigator"],
45
- lookupQuerystring: "lng",
46
- lookupCookie: "i18next",
47
- lookupLocalStorage: "i18nextLng",
48
- caches: ["localStorage", "cookie"],
49
- }),
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,
50
149
  )
51
- .init({
52
- ...i18nSharedOptions,
53
- // When the site owner has resolved a language upfront (via widget
54
- // config or data-language) we set `lng` explicitly so i18next skips
55
- // browser detection. This is the single source of truth for the
56
- // widget language and prevents the flash-of-wrong-language race
57
- // where child components read the browser-detected language on
58
- // first render before an effect can call changeLanguage().
59
- ...(lng ? { lng } : {}),
60
- backend: {
61
- backends: [
62
- HttpBackend, // if a namespace can't be loaded via normal http-backend loadPath, then the inMemoryLocalBackend will try to return the correct resources
63
- // 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 )
64
- resourcesToBackend(
65
- (language: string, namespace: string) =>
66
- import(getLoadPath(language, namespace)),
67
- ),
68
- ],
69
- backendOptions: [
70
- {
71
- loadPath: getLoadPath("{{lng}}", "{{ns}}"),
72
- },
73
- ],
74
- },
75
- ...detectionOptions,
76
- } as InitOptions);
77
- // "loaded" fires once per namespace, and again after changeLanguage(),
78
- // so the raw event is not the one-shot signal the callback expects.
79
- // Latch it here as well as in loadI18next: this callback is public and
80
- // a caller could reasonably use it to run a side effect.
81
- let notified = false;
82
- i18n.on("loaded", () => {
83
- if (notified) {
84
- 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");
85
220
  }
86
- notified = true;
87
- i18nLoadedCallback?.(i18n);
88
- });
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));
89
258
  } else {
90
- // Already initialised (e.g. createTranslator() ran first, or a second
91
- // widget mounted). The `loaded` handler is only registered in the init
92
- // branch, so the callback has to fire here or loadI18next() never
93
- // settles. Resources are already present, so resolving immediately is
94
- // correct; the caller reconciles the language afterwards.
95
- 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);
96
263
  }
97
- return i18n;
264
+ return instance;
98
265
  }
99
266
 
100
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,10 +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
22
  export { createTranslator } from "./translator.js";
22
23
  export type { Translator } from "./translator.js";
23
- export type { Ti18n } from "./loadI18next.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
  }
@@ -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;
@@ -84,7 +84,7 @@
84
84
  "JSON_LOAD_FAILED": "JSON-tiedoston lataaminen epäonnistui",
85
85
  "MNEMONIC_UNDEFINED": "Mnemonic Undefined. Aseta mnemonic ympäristömuuttujiin",
86
86
  "NO_MNEMONIC_OR_SEED": "Ei annettu mnemonicia tai siemenavainta",
87
- "CANT_FIND_KEYRINGPAIR": "Avainta ei löydy {{osoite}}",
87
+ "CANT_FIND_KEYRINGPAIR": "Avainta ei löydy {{address}}",
88
88
  "ENVIRONMENT_NOT_READY": "Ympäristö ei ole valmis",
89
89
  "INVALID_SIGNATURE": "Virheellinen allekirjoitus",
90
90
  "SITE_KEY_MISSING": "Sivuston avain puuttuu",