devex: dead locales cleanup + i18n inspect tool (#5313)
* 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
This commit is contained in:
223
packages/ui/src/composables/i18n-debug.ts
Normal file
223
packages/ui/src/composables/i18n-debug.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Ref } from 'vue'
|
||||
import type { CompileError, MessageCompiler, MessageContext } from 'vue-i18n'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
import { injectI18nDebug } from './i18n-debug'
|
||||
|
||||
export interface MessageDescriptor {
|
||||
id: string
|
||||
@@ -34,63 +35,36 @@ export interface LocaleDefinition {
|
||||
}
|
||||
|
||||
export const LOCALES: LocaleDefinition[] = [
|
||||
// { code: 'af-ZA', name: 'Afrikaans' },
|
||||
// { code: 'ar-EG', name: 'العربية (مصر)', dir: 'rtl' },
|
||||
// Commented out as it's RTL - will enable when we have better RTL support
|
||||
// { code: 'ar-SA', name: 'العربية (السعودية)', dir: 'rtl' },
|
||||
// { code: 'az-AZ', name: 'Azərbaycan' },
|
||||
// { code: 'be-BY', name: 'Беларуская' },
|
||||
// { code: 'bg-BG', name: 'Български' },
|
||||
// { code: 'bn-BD', name: 'বাংলা' },
|
||||
// { code: 'ca-ES', name: 'Català' },
|
||||
// { code: 'ceb-PH', name: 'Cebuano' },
|
||||
// { code: 'cs-CZ', name: 'Čeština' },
|
||||
// { code: 'da-DK', name: 'Dansk' },
|
||||
{ code: 'cs-CZ', name: 'Čeština' },
|
||||
{ code: 'da-DK', name: 'Dansk' },
|
||||
{ code: 'de-CH', name: 'Deutsch (Schweiz)' },
|
||||
{ code: 'de-DE', name: 'Deutsch' },
|
||||
// { code: 'el-GR', name: 'Ελληνικά' },
|
||||
// { code: 'en-PT', name: 'Pirate English' },
|
||||
// { code: 'en-UD', name: 'Upside Down' },
|
||||
{ code: 'en-US', name: 'English (United States)' },
|
||||
// { code: 'eo-UY', name: 'Esperanto' },
|
||||
{ code: 'es-419', name: 'Español (Latinoamérica)' },
|
||||
{ code: 'es-ES', name: 'Español (España)' },
|
||||
// { code: 'et-EE', name: 'Eesti' },
|
||||
// { code: 'fa-IR', name: 'فارسی', dir: 'rtl' },
|
||||
// { code: 'fi-FI', name: 'Suomi' },
|
||||
{ code: 'fi-FI', name: 'Suomi' },
|
||||
{ code: 'fil-PH', name: 'Filipino' },
|
||||
{ code: 'fr-FR', name: 'Français' },
|
||||
// { code: 'he-IL', name: 'עברית', dir: 'rtl' },
|
||||
// { code: 'hi-IN', name: 'हिन्दी' },
|
||||
// { code: 'hr-HR', name: 'Hrvatski' },
|
||||
// { code: 'hu-HU', name: 'Magyar' },
|
||||
{ code: 'he-IL', name: 'עברית', dir: 'rtl' },
|
||||
{ code: 'hu-HU', name: 'Magyar' },
|
||||
{ code: 'id-ID', name: 'Bahasa Indonesia' },
|
||||
// { code: 'is-IS', name: 'Íslenska' },
|
||||
{ code: 'it-IT', name: 'Italiano', numeric: 'always' },
|
||||
// { code: 'ja-JP', name: '日本語' },
|
||||
// { code: 'kk-KZ', name: 'Қазақша' },
|
||||
{ code: 'ja-JP', name: '日本語' },
|
||||
{ code: 'ko-KR', name: '한국어' },
|
||||
// { code: 'ky-KG', name: 'Кыргызча' },
|
||||
// { code: 'lol-US', name: 'LOLCAT' },
|
||||
// { code: 'lt-LT', name: 'Lietuvių' },
|
||||
// { code: 'lv-LV', name: 'Latviešu' },
|
||||
// { code: 'ms-Arab', name: 'بهاس ملايو (جاوي)', dir: 'rtl' },
|
||||
{ code: 'ms-MY', name: 'Bahasa Melayu' },
|
||||
{ code: 'nl-NL', name: 'Nederlands' },
|
||||
// { code: 'no-NO', name: 'Norsk' },
|
||||
{ code: 'no-NO', name: 'Norsk' },
|
||||
{ code: 'pl-PL', name: 'Polski' },
|
||||
{ code: 'pt-BR', name: 'Português (Brasil)' },
|
||||
{ code: 'pt-PT', name: 'Português (Portugal)' },
|
||||
// { code: 'ro-RO', name: 'Română' },
|
||||
{ code: 'ro-RO', name: 'Română' },
|
||||
{ code: 'ru-RU', name: 'Русский', numeric: 'always' },
|
||||
// { code: 'sk-SK', name: 'Slovenčina' },
|
||||
// { code: 'sl-SI', name: 'Slovenščina' },
|
||||
// { code: 'sr-CS', name: 'Српски (ћирилица)' },
|
||||
// { code: 'sr-SP', name: 'Srpski (latinica)' },
|
||||
// { code: 'sv-SE', name: 'Svenska' },
|
||||
// { code: 'th-TH', name: 'ไทย' },
|
||||
// { code: 'tl-PH', name: 'Tagalog' },
|
||||
{ code: 'sr-CS', name: 'Srpski (latinica)' },
|
||||
{ code: 'sv-SE', name: 'Svenska' },
|
||||
{ code: 'th-TH', name: 'ไทย' },
|
||||
{ code: 'tr-TR', name: 'Türkçe' },
|
||||
// { code: 'tt-RU', name: 'Татарча' },
|
||||
{ code: 'uk-UA', name: 'Українська' },
|
||||
{ code: 'vi-VN', name: 'Tiếng Việt' },
|
||||
{ code: 'zh-CN', name: '简体中文' },
|
||||
@@ -179,6 +153,7 @@ export interface VIntlFormatters {
|
||||
*/
|
||||
export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
|
||||
const { t, locale } = injectI18n()
|
||||
const debugContext = injectI18nDebug()
|
||||
|
||||
function formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string {
|
||||
// Read locale.value to ensure Vue tracks this as a reactive dependency
|
||||
@@ -188,18 +163,33 @@ export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
|
||||
const key = descriptor.id
|
||||
const translation = t(key, values ?? {})
|
||||
|
||||
let result: string
|
||||
if (translation && translation !== key) {
|
||||
return translation as string
|
||||
result = translation as string
|
||||
} else {
|
||||
// Fallback to defaultMessage if key not found
|
||||
const defaultMsg = descriptor.defaultMessage ?? key
|
||||
try {
|
||||
const formatter = new IntlMessageFormat(defaultMsg, locale.value)
|
||||
result = formatter.format(values ?? {}) as string
|
||||
} catch {
|
||||
result = defaultMsg
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to defaultMessage if key not found
|
||||
const defaultMsg = descriptor.defaultMessage ?? key
|
||||
try {
|
||||
const formatter = new IntlMessageFormat(defaultMsg, locale.value)
|
||||
return formatter.format(values ?? {}) as string
|
||||
} catch {
|
||||
return defaultMsg
|
||||
if (debugContext?.enabled.value) {
|
||||
debugContext.registry.set(key, {
|
||||
key,
|
||||
value: result,
|
||||
defaultMessage: descriptor.defaultMessage,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
if (debugContext.keyReveal.value) {
|
||||
return `\u300C${key}\u300D`
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return { formatMessage, locale }
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from './debug-logger'
|
||||
export * from './dynamic-font-size'
|
||||
export * from './how-ago'
|
||||
export * from './i18n'
|
||||
export * from './i18n-debug'
|
||||
export * from './scroll-indicator'
|
||||
|
||||
Reference in New Issue
Block a user