From c0004fba7ec5dbae4aa0270b942e82a3e445b44c Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Thu, 16 Jan 2025 16:50:59 +0530 Subject: [PATCH 1/2] improvement: add support for nested translations and ICU formatting --- packages/i18n/package.json | 3 +- packages/i18n/src/components/index.tsx | 29 ---- packages/i18n/src/components/store.ts | 42 ------ packages/i18n/src/config/index.ts | 45 ------ packages/i18n/src/constants/index.ts | 1 + packages/i18n/src/constants/language.ts | 13 ++ packages/i18n/src/context/index.tsx | 19 +++ packages/i18n/src/hooks/use-translation.ts | 32 +++- packages/i18n/src/index.ts | 5 +- packages/i18n/src/store/index.ts | 165 +++++++++++++++++++++ packages/i18n/src/types/index.ts | 2 + packages/i18n/src/types/language.ts | 6 + packages/i18n/src/types/translation.ts | 7 + web/core/lib/wrappers/store-wrapper.tsx | 4 +- yarn.lock | 63 +++++++- 15 files changed, 302 insertions(+), 134 deletions(-) delete mode 100644 packages/i18n/src/components/index.tsx delete mode 100644 packages/i18n/src/components/store.ts delete mode 100644 packages/i18n/src/config/index.ts create mode 100644 packages/i18n/src/constants/index.ts create mode 100644 packages/i18n/src/constants/language.ts create mode 100644 packages/i18n/src/context/index.tsx create mode 100644 packages/i18n/src/store/index.ts create mode 100644 packages/i18n/src/types/index.ts create mode 100644 packages/i18n/src/types/language.ts create mode 100644 packages/i18n/src/types/translation.ts diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 0a4d0562797..96b4880243b 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -10,7 +10,8 @@ "lint:errors": "eslint src --ext .ts,.tsx --quiet" }, "dependencies": { - "@plane/utils": "*" + "@plane/utils": "*", + "intl-messageformat": "^10.7.11" }, "devDependencies": { "@plane/eslint-config": "*", diff --git a/packages/i18n/src/components/index.tsx b/packages/i18n/src/components/index.tsx deleted file mode 100644 index 705b0ee4a15..00000000000 --- a/packages/i18n/src/components/index.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { observer } from "mobx-react"; -import React, { createContext, useEffect } from "react"; -import { Language, languages } from "../config"; -import { TranslationStore } from "./store"; - -// Create the store instance -const translationStore = new TranslationStore(); - -// Create Context -export const TranslationContext = createContext(translationStore); - -export const TranslationProvider = observer(({ children }: { children: React.ReactNode }) => { - // Handle storage events for cross-tab synchronization - useEffect(() => { - const handleStorageChange = (event: StorageEvent) => { - if (event.key === "userLanguage" && event.newValue) { - const newLang = event.newValue as Language; - if (languages.includes(newLang)) { - translationStore.setLanguage(newLang); - } - } - }; - - window.addEventListener("storage", handleStorageChange); - return () => window.removeEventListener("storage", handleStorageChange); - }, []); - - return {children}; -}); diff --git a/packages/i18n/src/components/store.ts b/packages/i18n/src/components/store.ts deleted file mode 100644 index 6524c0df271..00000000000 --- a/packages/i18n/src/components/store.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { makeObservable, observable } from "mobx"; -import { Language, fallbackLng, languages, translations } from "../config"; - -export class TranslationStore { - currentLocale: Language = fallbackLng; - - constructor() { - makeObservable(this, { - currentLocale: observable.ref, - }); - this.initializeLanguage(); - } - - get availableLanguages() { - return languages; - } - - t(key: string) { - return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key; - } - - setLanguage(lng: Language) { - try { - localStorage.setItem("userLanguage", lng); - this.currentLocale = lng; - } catch (error) { - console.error(error); - } - } - - initializeLanguage() { - if (typeof window === "undefined") return; - const savedLocale = localStorage.getItem("userLanguage") as Language; - if (savedLocale && languages.includes(savedLocale)) { - this.setLanguage(savedLocale); - } else { - const browserLang = navigator.language.split("-")[0] as Language; - const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng; - this.setLanguage(newLocale); - } - } -} diff --git a/packages/i18n/src/config/index.ts b/packages/i18n/src/config/index.ts deleted file mode 100644 index 170f3baacb5..00000000000 --- a/packages/i18n/src/config/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import en from "../locales/en/translations.json"; -import es from "../locales/es/translations.json"; -import fr from "../locales/fr/translations.json"; -import ja from "../locales/ja/translations.json"; -import zh_CN from "../locales/zh-CN/translations.json"; - -export type Language = (typeof languages)[number]; -export type Translations = { - [key: string]: { - [key: string]: string; - }; -}; - -export const fallbackLng = "en"; -export const languages = ["en", "fr", "es", "ja", "zh-CN"] as const; -export const translations: Translations = { - en, - fr, - es, - ja, - zh_CN, -}; - -export const SUPPORTED_LANGUAGES = [ - { - label: "English", - value: "en", - }, - { - label: "French", - value: "fr", - }, - { - label: "Spanish", - value: "es", - }, - { - label: "Japanese", - value: "ja", - }, - { - label: "Chinese", - value: "zh-CN", - }, -]; diff --git a/packages/i18n/src/constants/index.ts b/packages/i18n/src/constants/index.ts new file mode 100644 index 00000000000..1f0daf2fc25 --- /dev/null +++ b/packages/i18n/src/constants/index.ts @@ -0,0 +1 @@ +export * from "./language"; diff --git a/packages/i18n/src/constants/language.ts b/packages/i18n/src/constants/language.ts new file mode 100644 index 00000000000..67b8e9d3f66 --- /dev/null +++ b/packages/i18n/src/constants/language.ts @@ -0,0 +1,13 @@ +import { TLanguage, ILanguageOption } from "../types"; + +export const FALLBACK_LANGUAGE: TLanguage = "en"; + +export const SUPPORTED_LANGUAGES: ILanguageOption[] = [ + { label: "English", value: "en" }, + { label: "Français", value: "fr" }, + { label: "Español", value: "es" }, + { label: "日本語", value: "ja" }, + { label: "中文", value: "zh-CN" }, +]; + +export const STORAGE_KEY = "userLanguage"; diff --git a/packages/i18n/src/context/index.tsx b/packages/i18n/src/context/index.tsx new file mode 100644 index 00000000000..cf8a960fec1 --- /dev/null +++ b/packages/i18n/src/context/index.tsx @@ -0,0 +1,19 @@ +import { observer } from "mobx-react"; +import React, { createContext } from "react"; +// store +import { TranslationStore } from "../store"; + +export const TranslationContext = createContext(null); + +interface TranslationProviderProps { + children: React.ReactNode; +} + +/** + * Provides the translation store to the application + */ +export const TranslationProvider: React.FC = observer(({ children }) => { + const [store] = React.useState(() => new TranslationStore()); + + return {children}; +}); diff --git a/packages/i18n/src/hooks/use-translation.ts b/packages/i18n/src/hooks/use-translation.ts index f947d1d5eb5..485c437c177 100644 --- a/packages/i18n/src/hooks/use-translation.ts +++ b/packages/i18n/src/hooks/use-translation.ts @@ -1,17 +1,35 @@ -import { useContext } from "react"; -import { TranslationContext } from "../components"; -import { Language } from "../config"; +import { useContext } from 'react'; +// context +import { TranslationContext } from '../context'; +// types +import { ILanguageOption, TLanguage } from '../types'; -export function useTranslation() { +export type TTranslationStore = { + t: (key: string, params?: Record) => string; + currentLocale: TLanguage; + changeLanguage: (lng: TLanguage) => void; + languages: ILanguageOption[]; +}; + +/** + * Provides the translation store to the application + * @returns {TTranslationStore} + * @returns {(key: string, params?: Record) => string} t: method to translate the key with params + * @returns {TLanguage} currentLocale - current locale language + * @returns {(lng: TLanguage) => void} changeLanguage - method to change the language + * @returns {ILanguageOption[]} languages - available languages + * @throws {Error} if the TranslationProvider is not used + */ +export function useTranslation(): TTranslationStore { const store = useContext(TranslationContext); if (!store) { - throw new Error("useTranslation must be used within a TranslationProvider"); + throw new Error('useTranslation must be used within a TranslationProvider'); } return { - t: (key: string) => store.t(key), + t: store.t.bind(store), currentLocale: store.currentLocale, - changeLanguage: (lng: Language) => store.setLanguage(lng), + changeLanguage: (lng: TLanguage) => store.setLanguage(lng), languages: store.availableLanguages, }; } diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 639ef4b59a4..a93747a8992 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,3 +1,4 @@ -export * from "./config"; -export * from "./components"; +export * from "./constants"; +export * from "./context"; export * from "./hooks"; +export * from "./types"; diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts new file mode 100644 index 00000000000..34d4fce17d1 --- /dev/null +++ b/packages/i18n/src/store/index.ts @@ -0,0 +1,165 @@ +import IntlMessageFormat from "intl-messageformat"; +import get from "lodash/get"; +import { makeAutoObservable } from "mobx"; +// constants +import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants"; +// types +import { TLanguage, ILanguageOption, ITranslations } from "../types"; + +/** + * Mobx store class for handling translations and language changes in the application + * Provides methods to translate keys with params and change the language + * Uses IntlMessageFormat to format the translations + */ +export class TranslationStore { + // List of translations for each language + private translations: ITranslations = {}; + // Cache for IntlMessageFormat instances + private messageCache: Map = new Map(); + // Current language + currentLocale: TLanguage = FALLBACK_LANGUAGE; + + /** + * Constructor for the TranslationStore class + */ + constructor() { + makeAutoObservable(this); + this.initializeLanguage(); + this.loadTranslations(); + } + + /** + * Loads translations from JSON files and initializes the message cache + */ + private async loadTranslations() { + try { + // dynamic import of translations + const translations = { + en: (await import("../locales/en/translations.json")).default, + fr: (await import("../locales/fr/translations.json")).default, + es: (await import("../locales/es/translations.json")).default, + ja: (await import("../locales/ja/translations.json")).default, + "zh-CN": (await import("../locales/zh-CN/translations.json")).default, + }; + this.translations = translations; + this.messageCache.clear(); // Clear cache when translations change + } catch (error) { + console.error("Failed to load translations:", error); + } + } + + /** Initializes the language based on the local storage or browser language */ + private initializeLanguage() { + if (typeof window === "undefined") return; + + const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage; + if (this.isValidLanguage(savedLocale)) { + this.setLanguage(savedLocale); + return; + } + + const browserLang = this.getBrowserLanguage(); + this.setLanguage(browserLang); + } + + /** Checks if the language is valid based on the supported languages */ + private isValidLanguage(lang: string | null): lang is TLanguage { + return lang !== null && SUPPORTED_LANGUAGES.some((l) => l.value === lang); + } + + /** Gets the browser language based on the navigator.language */ + private getBrowserLanguage(): TLanguage { + const browserLang = navigator.language.split("-")[0]; + return this.isValidLanguage(browserLang) ? browserLang : FALLBACK_LANGUAGE; + } + + /** Gets the IntlMessageFormat instance for the given key and locale */ + private getCacheKey(key: string, locale: TLanguage): string { + return `${locale}:${key}`; + } + + /** + * Gets the IntlMessageFormat instance for the given key and locale + * Returns cached instance if available + * Throws an error if the key is not found in the translations + */ + private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null { + const cacheKey = this.getCacheKey(key, locale); + + // Check if the cache already has the key + if (this.messageCache.has(cacheKey)) { + return this.messageCache.get(cacheKey) || null; + } + + // Get the message from the translations + const message = get(this.translations[locale], key); + if (!message) return null; + + try { + const formatter = new IntlMessageFormat(message as any, locale); + this.messageCache.set(cacheKey, formatter); + return formatter; + } catch (error) { + console.error(`Failed to create message formatter for key "${key}":`, error); + return null; + } + } + + /** + * Translates a key with params using the current locale + * Falls back to the default language if the translation is not found + * Returns the key itself if the translation is not found + * @param key - The key to translate + * @param params - The params to format the translation with + * @returns The translated string + */ + t(key: string, params?: Record): string { + try { + // Try current locale + let formatter = this.getMessageInstance(key, this.currentLocale); + + // Fallback to default language if necessary + if (!formatter && this.currentLocale !== FALLBACK_LANGUAGE) { + formatter = this.getMessageInstance(key, FALLBACK_LANGUAGE); + } + + // If we have a formatter, use it + if (formatter) { + return formatter.format(params || {}) as string; + } + + // Last resort: return the key itself + return key; + } catch (error) { + console.error(`Translation error for key "${key}":`, error); + return key; + } + } + + /** + * Sets the current language and updates the translations + * @param lng - The new language + */ + setLanguage(lng: TLanguage): void { + try { + if (!this.isValidLanguage(lng)) { + throw new Error(`Invalid language: ${lng}`); + } + + localStorage.setItem(STORAGE_KEY, lng); + this.currentLocale = lng; + this.messageCache.clear(); // Clear cache when language changes + document.documentElement.lang = lng; + } catch (error) { + console.error("Failed to set language:", error); + } + } + + /** + * Gets the available language options for the dropdown + * @returns An array of language options + */ + get availableLanguages(): ILanguageOption[] { + return SUPPORTED_LANGUAGES; + } +} diff --git a/packages/i18n/src/types/index.ts b/packages/i18n/src/types/index.ts new file mode 100644 index 00000000000..d56ad1e16dd --- /dev/null +++ b/packages/i18n/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./language"; +export * from "./translation"; diff --git a/packages/i18n/src/types/language.ts b/packages/i18n/src/types/language.ts new file mode 100644 index 00000000000..86e141ff5c8 --- /dev/null +++ b/packages/i18n/src/types/language.ts @@ -0,0 +1,6 @@ +export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN"; + +export interface ILanguageOption { + label: string; + value: TLanguage; +} diff --git a/packages/i18n/src/types/translation.ts b/packages/i18n/src/types/translation.ts new file mode 100644 index 00000000000..b75705552a2 --- /dev/null +++ b/packages/i18n/src/types/translation.ts @@ -0,0 +1,7 @@ +export interface ITranslation { + [key: string]: string | ITranslation; +} + +export interface ITranslations { + [locale: string]: ITranslation; +} diff --git a/web/core/lib/wrappers/store-wrapper.tsx b/web/core/lib/wrappers/store-wrapper.tsx index eb8f7325ba1..0243c34f48b 100644 --- a/web/core/lib/wrappers/store-wrapper.tsx +++ b/web/core/lib/wrappers/store-wrapper.tsx @@ -2,7 +2,7 @@ import { ReactNode, useEffect, FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; -import { useTranslation, Language } from "@plane/i18n"; +import { useTranslation, TLanguage } from "@plane/i18n"; // helpers import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper"; // hooks @@ -53,7 +53,7 @@ const StoreWrapper: FC = observer((props) => { useEffect(() => { if (!userProfile?.language) return; - changeLanguage(userProfile?.language as Language); + changeLanguage(userProfile?.language as TLanguage); }, [userProfile?.language, changeLanguage]); useEffect(() => { diff --git a/yarn.lock b/yarn.lock index f0b3196a1b8..fa19410fda9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1397,6 +1397,47 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== +"@formatjs/ecma402-abstract@2.3.2": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz#0ee291effe7ee2c340742a6c95d92eacb5e6c00a" + integrity sha512-6sE5nyvDloULiyOMbOTJEEgWL32w+VHkZQs8S02Lnn8Y/O5aQhjOEXwWzvR7SsBE/exxlSpY2EsWZgqHbtLatg== + dependencies: + "@formatjs/fast-memoize" "2.2.6" + "@formatjs/intl-localematcher" "0.5.10" + decimal.js "10" + tslib "2" + +"@formatjs/fast-memoize@2.2.6": + version "2.2.6" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.6.tgz#fac0a84207a1396be1f1aa4ee2805b179e9343d1" + integrity sha512-luIXeE2LJbQnnzotY1f2U2m7xuQNj2DA8Vq4ce1BY9ebRZaoPB1+8eZ6nXpLzsxuW5spQxr7LdCg+CApZwkqkw== + dependencies: + tslib "2" + +"@formatjs/icu-messageformat-parser@2.9.8": + version "2.9.8" + resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.9.8.tgz#118e7156f8a8db6b27b650f09334db21456c681f" + integrity sha512-hZlLNI3+Lev8IAXuwehLoN7QTKqbx3XXwFW1jh0AdIA9XJdzn9Uzr+2LLBspPm/PX0+NLIfykj/8IKxQqHUcUQ== + dependencies: + "@formatjs/ecma402-abstract" "2.3.2" + "@formatjs/icu-skeleton-parser" "1.8.12" + tslib "2" + +"@formatjs/icu-skeleton-parser@1.8.12": + version "1.8.12" + resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.12.tgz#43076747cdbe0f23bfac2b2a956bd8219716680d" + integrity sha512-QRAY2jC1BomFQHYDMcZtClqHR55EEnB96V7Xbk/UiBodsuFc5kujybzt87+qj1KqmJozFhk6n4KiT1HKwAkcfg== + dependencies: + "@formatjs/ecma402-abstract" "2.3.2" + tslib "2" + +"@formatjs/intl-localematcher@0.5.10": + version "0.5.10" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz#1e0bd3fc1332c1fe4540cfa28f07e9227b659a58" + integrity sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q== + dependencies: + tslib "2" + "@headlessui/react@^1.7.13", "@headlessui/react@^1.7.19", "@headlessui/react@^1.7.3": version "1.7.19" resolved "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz" @@ -6027,7 +6068,7 @@ decimal.js-light@^2.4.1: resolved "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz" integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== -decimal.js@^10.4.3: +decimal.js@10, decimal.js@^10.4.3: version "10.4.3" resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== @@ -7993,6 +8034,16 @@ internmap@^1.0.0: resolved "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz" integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== +intl-messageformat@^10.7.11: + version "10.7.11" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.7.11.tgz#f24893b2a64e7b5ec29f9eceb4f1a58bde1346e0" + integrity sha512-IB2N1tmI24k2EFH3PWjU7ivJsnWyLwOWOva0jnXFa29WzB6fb0JZ5EMQGu+XN5lDtjHYFo0/UooP67zBwUg7rQ== + dependencies: + "@formatjs/ecma402-abstract" "2.3.2" + "@formatjs/fast-memoize" "2.2.6" + "@formatjs/icu-messageformat-parser" "2.9.8" + tslib "2" + invariant@^2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" @@ -12303,16 +12354,16 @@ tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@^1.8.1: version "1.14.1" resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: - version "2.8.1" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - tslib@~2.5.0: version "2.5.3" resolved "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz" From 784ba9224dd639a8b2f98002f2c32eab446d84cf Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Thu, 16 Jan 2025 17:03:11 +0530 Subject: [PATCH 2/2] chore: comment update --- packages/i18n/src/store/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/i18n/src/store/index.ts b/packages/i18n/src/store/index.ts index 34d4fce17d1..2566e92c330 100644 --- a/packages/i18n/src/store/index.ts +++ b/packages/i18n/src/store/index.ts @@ -73,7 +73,12 @@ export class TranslationStore { return this.isValidLanguage(browserLang) ? browserLang : FALLBACK_LANGUAGE; } - /** Gets the IntlMessageFormat instance for the given key and locale */ + /** + * Gets the cache key for the given key and locale + * @param key - the key to get the cache key for + * @param locale - the locale to get the cache key for + * @returns the cache key for the given key and locale + */ private getCacheKey(key: string, locale: TLanguage): string { return `${locale}:${key}`; }