import { getDocument } from "ssr-window"; import { constants } from "../../constants"; import { _isReactNode } from "../../utils/_isReactNode"; import { getAttr } from "../../utils/getAttr"; import { getSanitizedHTML } from "../../utils/getSanitizedHTML"; import { Collection } from "../collection"; import type { TCollectionElement } from "../collection"; import { CollectionItem } from "../collectionItem"; import { Localisation } from "../localisation"; import type { TLocalisationEvent } from "../localisation/types"; type TCollectionArgs = [ string, typeof Locale, boolean? ]; type TLocaleCfg = { code?: string; attr?: string | string[]; options?: Record; }; const isLocalisationEvent = (event: Event): event is TLocalisationEvent => { return event instanceof CustomEvent && typeof event.detail === "object" && event.detail !== null; }; const setupLocalisationInstance = (e: TLocalisationEvent) => { const instance = e && isLocalisationEvent(e) ? e.detail.instance : null; if (instance && !LocaleCollection.localisationInstance) { LocaleCollection.localisationInstance = instance; } }; /** * Класс для рендеринга элемента локали статичной разметки */ export class Locale extends CollectionItem { states: { lastTransition: string | null; }; isReact: boolean; constructor(el: TCollectionElement) { if (!(el instanceof HTMLElement)) { throw new TypeError(`[LocaleCollection] HTMLElement is expected, but got "${el?.constructor?.name || typeof el}"`); } super(el, LocaleCollection.selector); this.states = { lastTransition: null, }; this.isReact = _isReactNode(el); if (!this.isReact) { this.instance.classList.add(constants.stateClasses.translationLoading); if (LocaleCollection.localisationInstance) { this.translate = this.locale; } else { Localisation.onReady = this.#onLocalisationReady.bind(this); } } } #onLocalisationReady(instance: Localisation) { LocaleCollection.localisationInstance = instance; this.translate = this.locale; } get attr() { const isValidStr = (str: unknown): str is string => typeof str === "string" && !!str.length; const { attr } = this.cfg; if (isValidStr(attr)) { return attr; } if (Array.isArray(attr) && attr.every(isValidStr)) { return attr; } return null; } get options(): Record { return (typeof this.cfg.options === "object" && this.cfg.options !== null) ? this.cfg.options : {}; } get locale(): string { if (this.code && LocaleCollection.localisationInstance) { const instance = LocaleCollection.localisationInstance; return instance.getLocale(this.code, this.options); } return ""; } set translate(msg: string) { if (!!msg && this.states.lastTransition !== msg) { if (this.isReact) { // react render if (this.instance.classList.contains(constants.stateClasses.translationLoading)) { console.error(`[LocaleCollection] Detected unfinished locale render. You need to await "Localisation" plugin before render locales`, this.instance); } } else { // static markup const attr = this.attr; if (attr) { Array.from(this.instance.children).forEach((node) => { const userAttrs = typeof attr === "string" ? Localisation.utils.getUserAttrs({ [attr]: msg }) : Localisation.utils.getUserAttrs(Object.fromEntries(attr.map((currentAttr) => [ currentAttr, msg ]))); Object.entries(userAttrs).forEach(([ key, value ]) => { node.setAttribute(key, String(value)); }); }); } else { if (this.instance.innerHTML !== msg) { this.instance.innerHTML = getSanitizedHTML(msg); } } this.instance.classList.remove(constants.stateClasses.translationLoading); this.instance.classList.add(constants.stateClasses.translated); this.states.lastTransition = msg; } } } get code(): string | null { const cfgCode = (typeof this.cfg.code === "string" && this.cfg.code.length) ? this.cfg.code : null; return cfgCode || this.instance.getAttribute(getAttr(LocaleCollection.selector)); } } /** * Коллекция вызовов локализации для статичной разметки (режима SSG). * Следует запускать после инициализации плагина `Localisation` */ export class LocaleCollection extends Collection { /** * Признак регистрации коллекции в глобальном namespace Core. * @type {boolean} */ static isApp = true; /** * Текущий экземпляр плагина Localisation, используемый для перевода элементов коллекции. * @type {Localisation|null} */ static localisationInstance: Localisation | null = null; /** * CSS-селектор элементов локализации для автоподключения коллекции. * @type {string} */ static selector = "[data-js-locale]"; constructor(...args: TCollectionArgs) { const defaultArgs: TCollectionArgs = [ LocaleCollection.selector, Locale, true ]; const finalArgs = args.length ? args : defaultArgs; super(...finalArgs); Localisation.onReady = this.#onLocalisationReady.bind(this); } /** * Защищает React-элементы от попадания в статичную коллекцию * @returns {Boolean} */ isValid(el: TCollectionElement): boolean { return !_isReactNode(el); } watchUpdates() { getDocument().addEventListener(constants.localisationBubbles.localisationChange, this.#onLocalisationChange.bind(this)); } #onLocalisationChange(e: TLocalisationEvent) { setupLocalisationInstance(e); this.update(); } #onLocalisationReady(instance: Localisation) { LocaleCollection.localisationInstance = instance; this.watchUpdates(); this.update(); } update() { if (LocaleCollection.localisationInstance) { this.collection.forEach((instance) => { const code = instance.code; const options = instance.options; if (code) { const msg = LocaleCollection.localisationInstance!.getLocale(code, options) || ""; if (msg) { instance.translate = msg; } else { console.error(`[LocaleCollection] Missing locale for code "${code}"`); } } else { console.error(`[LocaleCollection] Missing "code" prop for element`, instance.instance); } }); } else { console.error(`[LocaleCollection] Can't do update, Localisation plugin is not ready`); } } }