import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue' import { useRoute } from 'vue-router' import { DEFAULT_LOCALE } from 'src/i18n/languages' import { useLocaleStore } from 'src/stores/localeStore' import { useRuntimeTranslationStore } from 'src/stores/runtimeTranslationStore' const textOriginals = new WeakMap() const attrOriginals = new WeakMap() const TRANSLATABLE_ATTRS = ['placeholder', 'title', 'aria-label'] const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA']) function normalizeRuntimeText (value) { return String(value || '').trim().replace(/\s+/g, ' ') } function preserveOuterWhitespace (original, translated) { const text = String(original || '') const leading = text.match(/^\s*/)?.[0] || '' const trailing = text.match(/\s*$/)?.[0] || '' return `${leading}${translated}${trailing}` } function shouldSkipNode (node) { const parent = node?.parentElement if (!parent) return true if (SKIP_TAGS.has(parent.tagName)) return true if (parent.closest?.('[data-no-runtime-i18n="true"]')) return true return false } export function useRuntimeDomTranslations () { const route = useRoute() const localeStore = useLocaleStore() const runtimeTranslations = useRuntimeTranslationStore() let observer = null let applyTimer = null function translateTextNode (node) { if (shouldSkipNode(node)) return const current = String(node.nodeValue || '') if (!normalizeRuntimeText(current)) return if (!textOriginals.has(node)) { textOriginals.set(node, current) } const original = textOriginals.get(node) const key = normalizeRuntimeText(original) const translated = localeStore.locale === DEFAULT_LOCALE ? key : runtimeTranslations.byText[key] const nextValue = preserveOuterWhitespace(original, translated || key) if (node.nodeValue !== nextValue) { node.nodeValue = nextValue } } function translateElementAttrs (el) { if (!el || el.nodeType !== Node.ELEMENT_NODE) return if (el.closest?.('[data-no-runtime-i18n="true"]')) return let originals = attrOriginals.get(el) if (!originals) { originals = {} attrOriginals.set(el, originals) } for (const attr of TRANSLATABLE_ATTRS) { if (!el.hasAttribute(attr)) continue const current = el.getAttribute(attr) if (!normalizeRuntimeText(current)) continue if (!Object.prototype.hasOwnProperty.call(originals, attr)) { originals[attr] = current } const key = normalizeRuntimeText(originals[attr]) const translated = localeStore.locale === DEFAULT_LOCALE ? key : runtimeTranslations.byText[key] const nextValue = translated || key if (el.getAttribute(attr) !== nextValue) { el.setAttribute(attr, nextValue) } } } function translateRoot (root = document.body) { if (typeof document === 'undefined' || !root) return const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { acceptNode (node) { if (node.nodeType === Node.TEXT_NODE) { return normalizeRuntimeText(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT } return NodeFilter.FILTER_ACCEPT } } ) let node = walker.currentNode while (node) { if (node.nodeType === Node.TEXT_NODE) { translateTextNode(node) } else if (node.nodeType === Node.ELEMENT_NODE) { translateElementAttrs(node) } node = walker.nextNode() } } function scheduleApply () { if (applyTimer) clearTimeout(applyTimer) applyTimer = setTimeout(async () => { applyTimer = null await nextTick() translateRoot(document.body) }, 30) } onMounted(async () => { await runtimeTranslations.loadLocale(localeStore.locale) scheduleApply() observer = new MutationObserver(() => scheduleApply()) observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: TRANSLATABLE_ATTRS }) }) watch( () => localeStore.locale, async (locale) => { await runtimeTranslations.loadLocale(locale) scheduleApply() } ) watch( () => route.fullPath, () => scheduleApply() ) watch( () => runtimeTranslations.version, () => scheduleApply() ) onBeforeUnmount(() => { if (observer) observer.disconnect() if (applyTimer) clearTimeout(applyTimer) }) }