import type { InjectionKey, Ref } from 'vue' import { inject, provide, watch } from 'vue' export interface RegistryEntry { key: string value: string defaultMessage?: string timestamp: number } export interface I18nDebugContext { enabled: Ref keyReveal: Ref registry: Map panelOpen: Ref } export const I18N_DEBUG_KEY: InjectionKey = Symbol('i18n-debug') export function provideI18nDebug(context: I18nDebugContext): void { provide(I18N_DEBUG_KEY, context) } export function injectI18nDebug(): I18nDebugContext | null { return inject(I18N_DEBUG_KEY, null) } export function buildCrowdinUrl(key: string, locale: string): string { return `https://crowdin.com/translate/modrinth-platform/all/en-${locale}?filter=basic&value=0&search_type=identifier&search=${encodeURIComponent(key)}` } export function initI18nDebugRuntime(context: I18nDebugContext): void { import('@modrinth/assets/styles/i18n-debug.css') document.body.classList.add('i18n-debug') startMutationObserver(context.registry, context.keyReveal) setupKeyTooltip() registerKeyboardShortcuts(context.panelOpen, context.keyReveal) } function startMutationObserver(registry: Map, keyReveal: Ref) { let pending = false const observer = new MutationObserver((mutations) => { if (pending || keyReveal.value) return pending = true requestAnimationFrame(() => { pending = false if (!keyReveal.value) { processMutations(mutations, registry) } }) }) observer.observe(document.body, { childList: true, subtree: true, characterData: true, }) // Re-annotate whenever the registry grows (keys register after render) let annotateTimer: ReturnType | undefined let lastSize = 0 watch( () => registry.size, (size) => { if (size <= lastSize || keyReveal.value) return lastSize = size clearTimeout(annotateTimer) annotateTimer = setTimeout(() => annotateFullDocument(registry), 200) }, { immediate: true }, ) } function processMutations(mutations: MutationRecord[], registry: Map) { const reverseLookup = new Map() for (const [, entry] of registry) { if (entry.value) { reverseLookup.set(entry.value, entry.key) } } if (reverseLookup.size === 0) return for (const mutation of mutations) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { annotateTextNodes(node as Element, reverseLookup) } else if (node.nodeType === Node.TEXT_NODE) { annotateTextNode(node as Text, reverseLookup) } } for (const node of mutation.removedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { clearStaleAttributes(node as Element) } } } else if (mutation.type === 'characterData') { if (mutation.target.nodeType === Node.TEXT_NODE) { annotateTextNode(mutation.target as Text, reverseLookup) } } } } function annotateTextNodes(element: Element, reverseLookup: Map) { if (element.closest('.i18n-debug-panel')) return const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) let node: Text | null while ((node = walker.nextNode() as Text | null)) { annotateTextNode(node, reverseLookup) } } function annotateTextNode(node: Text, reverseLookup: Map) { const parent = node.parentElement if (!parent || parent.closest('.i18n-debug-panel')) return const text = node.textContent?.trim() if (!text) return const key = reverseLookup.get(text) if (key) { parent.setAttribute('data-i18n-key', key) } } export function clearAllAnnotations() { document.querySelectorAll('[data-i18n-key]').forEach((el) => { el.removeAttribute('data-i18n-key') }) } export function hideKeyTooltip() { const tooltip = document.querySelector('.i18n-key-tooltip') as HTMLElement | null if (tooltip) tooltip.style.display = 'none' } function clearStaleAttributes(element: Element) { if (element.hasAttribute?.('data-i18n-key')) { element.removeAttribute('data-i18n-key') } const children = element.querySelectorAll?.('[data-i18n-key]') if (children) { for (const child of children) { child.removeAttribute('data-i18n-key') } } } export function annotateFullDocument(registry: Map) { const reverseLookup = new Map() for (const [, entry] of registry) { if (entry.value) { reverseLookup.set(entry.value, entry.key) } } if (reverseLookup.size === 0) return annotateTextNodes(document.body, reverseLookup) } function setupKeyTooltip() { const tooltip = document.createElement('div') tooltip.className = 'i18n-key-tooltip' tooltip.style.display = 'none' document.body.appendChild(tooltip) let activeTarget: Element | null = null function positionTooltip() { if (!activeTarget) return const rect = activeTarget.getBoundingClientRect() const tooltipRect = tooltip.getBoundingClientRect() let top = rect.top - tooltipRect.height - 6 if (top < 4) top = rect.bottom + 6 let left = rect.left if (left + tooltipRect.width > window.innerWidth - 4) { left = window.innerWidth - tooltipRect.width - 4 } tooltip.style.top = `${top}px` tooltip.style.left = `${left}px` } document.body.addEventListener('mouseover', (e) => { const target = (e.target as Element).closest?.('[data-i18n-key]') if (!target) return const key = target.getAttribute('data-i18n-key') if (!key) return activeTarget = target tooltip.textContent = key tooltip.style.display = '' positionTooltip() }) document.body.addEventListener('mouseout', (e) => { const target = (e.target as Element).closest?.('[data-i18n-key]') if (!target) return const related = (e as MouseEvent).relatedTarget as Element | null if (related?.closest?.('[data-i18n-key]') === target) return activeTarget = null tooltip.style.display = 'none' }) document.addEventListener('scroll', () => positionTooltip(), { capture: true, passive: true }) } function registerKeyboardShortcuts(panelOpen: Ref, keyReveal: Ref) { document.addEventListener('keydown', (e: KeyboardEvent) => { // Use Cmd on macOS, Ctrl on other platforms const mod = e.metaKey || e.ctrlKey if (!mod || !e.shiftKey) return if (e.code === 'Period') { e.preventDefault() panelOpen.value = !panelOpen.value } else if (e.code === 'KeyK') { e.preventDefault() keyReveal.value = !keyReveal.value } }) }