import { bubble } from "@web3r/flowerkit/evt"; import { getMergedObj } from "@web3r/flowerkit/obj"; import i18next from "i18next"; import iso from "iso-3166-1"; import { initReactI18next } from "react-i18next"; import { getDocument } from "ssr-window"; import type { TLocaleFn, TLocalisationCfg, TLocalisationCfgInput, TLocalisationOnReady, TLocalisationResourceAddedEvent, } from "./types"; import * as localisationUtils from "./utils"; import type { TLocaleSource } from "./utils"; import { constants } from "../../constants"; /** * Singleton. Плагин локализации. * Использует библиотеку `i18next`. Локаль по умолчанию указывается в атрибуте `lang` корневого элемента документа. * @module Localisation */ export class Localisation { cfg: TLocalisationCfg; lib: typeof i18next; /** * Набор утилитарных функций * @type {typeof localisationUtils} */ static utils = localisationUtils; /** * Singleton-экземпляр плагина локализации. * @type {Localisation|null} */ static instance: Localisation | null = null; static #loadingPromise: Promise | null = null; static #pendingInstance: Localisation | null = null; /** * Признак выполнения кода в SSR-окружении (без объекта window). * @type {boolean} */ static isSSR = typeof window === "undefined"; /** * Признак регистрации плагина в глобальном namespace Core. * @type {boolean} */ static isApp = true; /** * Состояния жизненного цикла инициализации локализации. * @type {{isReady: boolean, isError: boolean, isLoading: boolean}} */ static states = { isReady: false, isError: false, isLoading: false, }; /** * Признак готовности плагина локализации. * @type {boolean} */ static get isReady() { return Localisation.states.isReady; } /** * Признак выполнения инициализации. * @type {boolean} */ static get isLoading() { return Localisation.states.isLoading; } /** * Признак ошибки инициализации. * @type {boolean} */ static get isError() { return Localisation.states.isError; } /** * Конфигурация Localisation по умолчанию. * @type {TLocalisationCfg} */ static defaultCfg: TLocalisationCfg = { locale: "", langKeys: constants.defaultLangKeys, locales: {}, isDebug: false, fallbackNS: "translation", load: "LanguageOnly", onMiss: null, onChange: null, onReady: null, onError: null, }; static #getErrorMsg = (type = "") => { const prefix = `[${Localisation.constructor.name}] `; const getErrBody = () => { switch (type) { case "custom": return ""; case "loading": return "i18next is already loading"; case "ns": return "Param `ns` are not defined"; case "lang": return "Param `lang` are not defined"; case "code": return "Code not found in current locales"; case "bundle": return "Resource bundle already exist"; default: return "Unexpected error"; } }; return prefix + getErrBody(); }; // Хранилище колбэков готовности с явным указанием дженерика static #onReadyCallbacks = new Set>(); // Хранилище колбэков добавления ресурсов static #resourceListeners = new Set<(data: TLocalisationResourceAddedEvent) => void>(); /** * Создаёт или возвращает существующий экземпляр плагина локализации. * @param cfg{Object=} пользовательская конфигурация * @returns {Promise} промис с экземпляром плагина */ static create(cfg: TLocalisationCfgInput = {}): Promise { if (Localisation.instance) { return Promise.resolve(Localisation.instance); } if (Localisation.#loadingPromise) { return Localisation.#loadingPromise; } // Конструктор устанавливает #loadingPromise, возвращаем его new Localisation(cfg); return Localisation.#loadingPromise!; } /** * Не используйте конструктор напрямую. Воспользуйтесь статическим методом `Localisation.create()`. * @param cfg{Object=} пользовательская конфигурация * @throws {Error} при попытке повторного создания экземпляра */ constructor(cfg: TLocalisationCfgInput = {}) { if (Localisation.instance || Localisation.#pendingInstance) { throw new Error( `[Localisation] Use Localisation.create() instead of new Localisation()` ); } this.cfg = this.#getCfg(cfg); this.lib = i18next; Localisation.#pendingInstance = this; if (typeof this.cfg.onReady === "function") { Localisation.addReadyCallback(this.cfg.onReady); } // установка языка if (typeof this.cfg.locale === "string" && this.cfg.locale.length) { const split = this.cfg.locale.split("-"); if (split.length > 1) { console.error(`[Localisation] Wrong value for locale, use short format instead: "ru-RU" => "ru"`); } this.cfg.locale = Localisation.#getResolvedLang(this.cfg.locale); } else { this.cfg.locale = this.#getUserLanguage(); } Localisation.#loadingPromise = this.#init() .then(() => { Localisation.instance = this; if (!Localisation.isSSR) { console.debug(`[Localisation] Initialized with lang "${i18next.language}" (supported: "${i18next?.languages?.join(", ")}")`); } return this; }) .catch((err) => { this.#onError(err); return this; }) .finally(() => { Localisation.states.isLoading = false; Localisation.#pendingInstance = null; Localisation.#loadingPromise = null; }); } /** * Получает объединённую конфигурацию плагина * @param cfg{Object=} пользовательская конфигурация * @returns {Object} итоговая конфигурация */ #getCfg(cfg: TLocalisationCfgInput = {}): TLocalisationCfg { return { ...Localisation.defaultCfg, ...cfg, }; } /** * Регистрирует callback, вызываемый при готовности плагина локализации. * Если плагин уже готов, callback будет вызван немедленно. * @param callback{Function} callback-функция */ static addReadyCallback(callback: TLocalisationOnReady): void { if (typeof callback !== "function") { console.error(`[Localisation] Callback must be function, but got "${typeof callback}"`); return; } // Если локализация уже готова if (Localisation.instance && Localisation.isReady) { callback(Localisation.instance); return; } // Иначе сохраняем для будущего вызова if (!Localisation.#onReadyCallbacks.has(callback)) { Localisation.#onReadyCallbacks.add(callback); } } /** * Устанавливает callback, вызываемый при готовности плагина локализации. * @param callback{Function|null|undefined} callback-функция * @deprecated Используйте статический метод `Localisation.addReadyCallback()` */ static set onReady(callback: TLocalisationOnReady | null | undefined) { if (callback === null || typeof callback === "undefined") { return; } Localisation.addReadyCallback(callback); } /** * Подписка на добавление ресурсов локализации. * Возвращает функцию для отписки. * @param listener{Function} обработчик события добавления ресурсов * @returns {Function} функция для отписки */ static onResourceAdded(listener: (data: TLocalisationResourceAddedEvent) => void): () => void { Localisation.#resourceListeners.add(listener); return () => { Localisation.#resourceListeners.delete(listener); }; } /** * Уведомляет всех подписчиков о добавлении ресурсов */ static #notifyResourceAdded(data: Omit): void { const eventData: TLocalisationResourceAddedEvent = { isSilent: !Localisation.isSSR, ...data, isHasCode: (code: string) => { return data.keys.some((key) => { // Точное совпадение if (key === code) { return true; } // Код может быть вложенным ключом (например: code = "forms.invalidInput", key = "forms") if (code.startsWith(key + ".")) { return true; } // Ключ может быть родительским для code (например: code = "forms", key = "forms.invalidInput") if (key.startsWith(code + ".")) { return true; } return false; }); }, }; if (!eventData.isSilent) { console.debug(`[Localisation] Resources added:`, eventData); } Localisation.#resourceListeners.forEach((listener) => { try { listener(eventData); } catch (err) { console.error("[Localisation] Error in resource listener:", err); } }); } /** * Получает текущий языковой код * @returns {string} * @see https://www.i18next.com/overview/api#language */ get lang() { return this.lib?.language ?? ""; } /** * Получает все доступные языковые коды * @returns {Array} * @see https://www.i18next.com/overview/api#languages */ get langList() { return Object.keys(this.lib?.services?.resourceStore?.data ?? []); } #onReady() { if (!Localisation.instance) { Localisation.instance = this; } Localisation.states.isReady = true; // всплывающее событие готовности плагина if (!Localisation.isSSR) { bubble(getDocument(), constants.localisationBubbles.localisationReady, { instance: this }); } // вызов коллбэков готовности плагина Localisation.#onReadyCallbacks.forEach((callback) => { try { callback(this); } catch (err) { console.error("[Localisation] Error in onReady callback:", err); } }); // очистка коллбэков Localisation.#onReadyCallbacks.clear(); } #onError(args: unknown) { Localisation.states.isError = true; if (this.cfg.isDebug) { console.warn(Localisation.#getErrorMsg("custom"), args); } if (!Localisation.isSSR) { bubble(getDocument(), constants.localisationBubbles.localisationError, { instance: this, err: args }); } if (typeof this.cfg.onError === "function") { try { this.cfg.onError(this, args); } catch (err) { console.error("[Localisation] Error in onError callback:", err); } } } #onChange(lang: string) { if (!Localisation.isSSR) { bubble(getDocument(), constants.localisationBubbles.localisationChange, { instance: this, lang }); } if (typeof this.cfg.onChange === "function") { try { this.cfg.onChange(this, lang); } catch (err) { console.error("[Localisation] Error in onChange callback:", err); } } } #onMiss(args: unknown) { if (!Localisation.isSSR) { bubble(getDocument(), constants.localisationBubbles.localisationMiss, { instance: this, miss: args }); } if (typeof this.cfg.onMiss === "function") { try { this.cfg.onMiss(this, args); } catch (err) { console.error("[Localisation] Error in onMiss callback:", err); } } } #bindEvents() { this.lib.on("missingKey", (...args) => this.#onMiss(args)); this.lib.on("languageChanged", (lang) => this.#onChange(lang)); this.lib.on("failedLoading", (...args) => this.#onError(args)); this.lib.on("initialized", () => this.#onReady()); } /** * Получает язык с кодом региона в формате `ISO-3166` * @param lang{String} код языка (например, "ru", "en") * @returns {String|null} язык с регионом (например, "ru_RU", "en_US") или null */ static getLangWithRegion(lang: unknown) { if (typeof lang !== "string" || lang.length === 0) { return null; } // Уже в правильном формате? if (/^[a-z]{2}_[A-Z]{2}$/.test(lang)) { return lang; } // Попробуем разобрать входной код const parts = lang.split(/[_-]/); let language = parts[0]?.toLowerCase(); if (!language) { console.error(`[Localisation] Invalid language code: ${lang}`); return null; } // Обработка специальных случаев switch (language) { case "en": return "en_US"; // en -> en_US case "rus": language = "ru"; break; } // Если у нас уже есть регион if (parts.length >= 2) { const region = parts[1]?.toUpperCase(); if (region && /^[A-Z]{2}$/.test(region)) { const isoRegion = iso.whereAlpha2(region)?.alpha2; if (isoRegion) { return `${language}_${isoRegion}`; } } } // По умолчанию используем язык как регион (например: fr -> fr_FR) const isoRegion = iso.whereAlpha2(language)?.alpha2; if (isoRegion) { return `${language}_${isoRegion}`; } console.error(`[Localisation] Can't find ISO-3166 region for code: ${lang}`); return null; } static #getResolvedLang(str: string) { const [ lang ] = str.split("-"); return !!lang ? lang.toLowerCase() : constants.defaultLangKeys[0]; } /** * Получает язык, указанный разработчиком для пользователя (из атрибута lang документа) * @returns {String} код языка */ #getUserLanguage() { if (Localisation.isSSR) { return constants.defaultLangKeys[0]; } const htmlLang = getDocument().documentElement.lang; if (htmlLang) { return Localisation.#getResolvedLang(htmlLang); } else { return constants.defaultLangKeys[0]; } } async #init() { if (Localisation.states.isLoading) { if (Localisation.#loadingPromise) { return Localisation.#loadingPromise.then(() => undefined); } return; } Localisation.states.isLoading = true; this.#bindEvents(); const { isDebug = false, fallbackNS, locale, locales, langKeys, } = this.cfg; return await i18next .use(initReactI18next) .init({ fallbackLng: langKeys, fallbackNS, supportedLngs: langKeys, lng: locale, debug: isDebug, interpolation: { escapeValue: false, }, resources: Localisation.utils.getResourcesFromObj({ data: getMergedObj(locales, constants.defaultLocales, { isMergeArrays: false }), langKeys, }), }); } /** * Загружает новое пространство имён * @param ns{String} название пространства имён * @param onError{Function} обработчик ошибок * @returns {Promise} промис с экземпляром плагина * @see https://www.i18next.com/principles/namespaces */ async getNewNS(ns: string | readonly string[], onError?: (error: unknown) => void): Promise { if (!ns) { throw new Error(Localisation.#getErrorMsg("ns")); } // Собираем информацию о добавляемых namespace const nsArray = Array.isArray(ns) ? ns : [ ns ]; await this.lib.loadNamespaces(ns, onError); // Уведомляем о добавлении ресурсов для каждого namespace nsArray.forEach((namespace) => { const bundle = this.lib.getResourceBundle(this.lang, namespace); const keys = bundle ? Object.keys(bundle) : []; Localisation.#notifyResourceAdded({ lang: this.lang, ns: namespace, keys, source: "getNewNS", }); }); return this; } /** * Меняет текущий язык интерфейса * @param lang{String=} код языка, по умолчанию язык пользователя * @param callback{Function=} callback-функция * @returns {Promise} промис с экземпляром плагина * @see https://www.i18next.com/overview/api#changelanguage */ async getChangeLanguage(lang = this.#getUserLanguage(), callback?: (error: unknown) => void): Promise { await this.lib.changeLanguage(lang, callback); return this; } /** * Проверяет существование локали по ключу * @param key{String=} название ключа (например, "forms.invalidInput") * @param options{Object=} опции проверки * @returns {Boolean} результат проверки * @see https://www.i18next.com/overview/api#exists */ isLocaleExist(key = "", options: Record = {}): boolean { return this.lib.exists(key, options); } /** * Получает локализованную строку по ключу * @param key{String|String[]=} название ключа или массив ключей (например, "forms.invalidInput") * @param options{Object=} опции получения (интерполяция, контекст и т.д.) * @returns {String} локализованная строка или пустая строка, если ключ не найден * @see https://www.i18next.com/overview/api#t */ getLocale(key: string | string[] = "", options: Record = {}) { const keysToCheck = Array.isArray(key) ? key : [ key ]; const isExist = keysToCheck.some((keyToCheck) => { return this.isLocaleExist(keyToCheck, options); }); if (isExist) { return String(this.lib.t(key, options)); } else { return ""; } } /** * Получает функцию локализации с фиксированными параметрами * @param code{String|String[]} код локали * @param lang{String=} код языка * @param ns{String=} пространство имён * @returns {Function} функция для получения локализованных строк * @see https://www.i18next.com/overview/api#getfixedt */ getLocaleFn(code: string | string[], lang = this.lang, ns = this.cfg.fallbackNS): TLocaleFn { const fixedT = this.lib.getFixedT(lang, ns); const withPrefix = (key: string): string => { if (Array.isArray(code)) { return code.length ? `${code.join(".")}.${key}` : key; } return code ? `${code}.${key}` : key; }; return (key, options = {}) => { const resolvedKey = Array.isArray(key) ? key.map(withPrefix) : withPrefix(key); return String(fixedT(resolvedKey, options)); }; } /** * Получает ресурс локализации по пространству имён * @param namespace{String} пространство имён * @param lang{String=} код языка * @param ns{String=} пространство имён * @returns {Object} объект ресурса */ getResource(namespace: string, lang: string | undefined = this.lang, ns: string | undefined = this.cfg.fallbackNS) { return this.lib.getResource(lang, ns, namespace); } /** * Добавляет новые данные локализации для текущих языков * @returns {Promise} * @see https://www.i18next.com/overview/api#addresources */ async getResourceAppend(locales: TLocaleSource = {}, isFlat = true, isSilent = false): Promise { const data = Localisation.utils.getResourcesFromObj({ data: locales, langKeys: this.cfg.langKeys, }); for (const [ lang, typeStorage ] of Object.entries(data)) { const langResources = typeStorage[this.cfg.fallbackNS]; if (!langResources) { continue; } if (isFlat) { const flatValues = Localisation.utils.getFlattenLanguageResources(langResources); const addedKeys = Object.keys(flatValues); if (addedKeys.length > 0) { this.lib.addResources(lang, this.cfg.fallbackNS, flatValues); Localisation.#notifyResourceAdded({ isSilent, lang, ns: this.cfg.fallbackNS, keys: addedKeys, source: "getResourceAppend", }); } } else { for (const [ ns, values ] of Object.entries(langResources)) { const addedKeys = Localisation.utils.isRecord(values) ? Object.keys(values) : []; if (addedKeys.length > 0) { this.lib.addResourceBundle(lang, ns, values); Localisation.#notifyResourceAdded({ isSilent, lang, ns, keys: addedKeys, source: "getResourceAppend", }); } } } } Localisation.states.isError = false; return this; } /** * Добавляет новый ресурс локализации для указанного языка и пространства имён * @param params{Object} параметры * @param params.lang{String=} код языка * @param params.ns{String=} пространство имён * @param params.locales{Object=} объект локализации * @param params.isOverride{Boolean=} заменять существующий ресурс * @returns {Promise} промис с экземпляром плагина * @see https://www.i18next.com/overview/api#addresourcebundle */ async getNewResource({ lang = this.lang, ns = this.cfg.fallbackNS, locales = constants.defaultLocales, isOverride = false, }): Promise { if (!ns) { throw new Error(Localisation.#getErrorMsg("ns")); } // Ждём готовности, если плагин ещё не инициализирован if (!Localisation.isReady && Localisation.#loadingPromise) { await Localisation.#loadingPromise; } if (this.lib.hasResourceBundle(lang, ns) && !isOverride) { throw new Error(Localisation.#getErrorMsg("bundle")); } // Собираем ключи, которые будут добавлены const addedKeys = typeof locales === "object" && locales !== null ? Object.keys(locales as Record) : []; this.lib.addResourceBundle(lang, ns, locales); // Уведомляем о добавлении ресурсов Localisation.#notifyResourceAdded({ lang, ns, keys: addedKeys, source: "getNewResource", }); Localisation.states.isError = false; return this; } }