* chore: remove old locales + just enable all locales now * feat: debug panel for i18n + tooltips * feat: dedupe * fix: debugger for app * fix: crowdin code mismatches * fix: lint
224 lines
6.4 KiB
TypeScript
224 lines
6.4 KiB
TypeScript
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<boolean>
|
|
keyReveal: Ref<boolean>
|
|
registry: Map<string, RegistryEntry>
|
|
panelOpen: Ref<boolean>
|
|
}
|
|
|
|
export const I18N_DEBUG_KEY: InjectionKey<I18nDebugContext> = 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<string, RegistryEntry>, keyReveal: Ref<boolean>) {
|
|
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<typeof setTimeout> | 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<string, RegistryEntry>) {
|
|
const reverseLookup = new Map<string, string>()
|
|
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<string, string>) {
|
|
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<string, string>) {
|
|
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<string, RegistryEntry>) {
|
|
const reverseLookup = new Map<string, string>()
|
|
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<boolean>, keyReveal: Ref<boolean>) {
|
|
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
|
|
}
|
|
})
|
|
}
|