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:
Calum H.
2026-02-09 16:00:46 +00:00
committed by GitHub
parent e3e04931cf
commit a536d795f3
188 changed files with 1030 additions and 26766 deletions

View File

@@ -0,0 +1,542 @@
<script setup lang="ts">
import {
EyeIcon,
EyeOffIcon,
MaximizeIcon,
MinusIcon,
ScanEyeIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { injectI18nDebug } from '../../composables/i18n-debug'
import ButtonStyled from './ButtonStyled.vue'
import StyledInput from './StyledInput.vue'
const debugContext = injectI18nDebug()
const searchQuery = ref('')
const minimized = ref(false)
const copiedKey = ref<string | null>(null)
const highlightedEl = ref<Element | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const activeEntryIndex = ref(-1)
const listContainerRef = ref<HTMLElement | null>(null)
// Dragging state
const isDragging = ref(false)
const panelPos = ref({ x: -1, y: -1 })
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const panelWidth = ref(380)
const panelHeight = ref(420)
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
const filteredEntries = computed(() => {
if (!debugContext) return []
const entries = Array.from(debugContext.registry.values())
const q = searchQuery.value.toLowerCase()
if (!q) return entries
return entries.filter((e) => e.key.toLowerCase().includes(q) || e.value.toLowerCase().includes(q))
})
const keyCount = computed(() => debugContext?.registry.size ?? 0)
// Reset active index when search changes
watch(searchQuery, () => {
activeEntryIndex.value = -1
})
function truncate(str: string, max: number): string {
return str.length > max ? str.slice(0, max) + '\u2026' : str
}
function highlightMatch(text: string, query: string): string {
if (!query) return escapeHtml(text)
const escaped = escapeHtml(text)
const q = escapeHtml(query)
const regex = new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return escaped.replace(regex, '<mark class="bg-brand/20 text-brand rounded-sm px-0.5">$1</mark>')
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function toggleKeyReveal() {
if (debugContext) {
debugContext.keyReveal.value = !debugContext.keyReveal.value
}
}
function toggleOverlay() {
if (debugContext?.enabled.value) {
document.body.classList.toggle('i18n-debug')
}
}
function closePanel() {
if (debugContext) {
debugContext.panelOpen.value = false
}
}
function highlightElement(key: string) {
clearHighlight()
const el = document.querySelector(`[data-i18n-key="${CSS.escape(key)}"]`)
if (el) {
highlightedEl.value = el
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
;(el as HTMLElement).style.outline = '2px solid var(--color-brand)'
;(el as HTMLElement).style.outlineOffset = '3px'
;(el as HTMLElement).style.borderRadius = '4px'
}
}
function clearHighlight() {
if (highlightedEl.value) {
;(highlightedEl.value as HTMLElement).style.outline = ''
;(highlightedEl.value as HTMLElement).style.outlineOffset = ''
;(highlightedEl.value as HTMLElement).style.borderRadius = ''
highlightedEl.value = null
}
}
async function copyKey(key: string) {
try {
await navigator.clipboard.writeText(key)
copiedKey.value = key
setTimeout(() => {
copiedKey.value = null
}, 2000)
} catch {
// clipboard not available
}
}
function onPanelKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
activeEntryIndex.value = Math.min(activeEntryIndex.value + 1, filteredEntries.value.length - 1)
scrollActiveIntoView()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeEntryIndex.value = Math.max(activeEntryIndex.value - 1, 0)
scrollActiveIntoView()
} else if (e.key === 'Enter' && activeEntryIndex.value >= 0) {
e.preventDefault()
const entry = filteredEntries.value[activeEntryIndex.value]
if (entry) copyKey(entry.key)
} else if (e.key === 'Escape') {
if (searchQuery.value) {
searchQuery.value = ''
} else {
closePanel()
}
} else if (e.key === '/' && document.activeElement !== searchInputRef.value) {
e.preventDefault()
searchInputRef.value?.focus()
}
}
function scrollActiveIntoView() {
nextTick(() => {
const activeEl = listContainerRef.value?.querySelector('[data-active="true"]')
activeEl?.scrollIntoView({ block: 'nearest' })
})
}
// Drag handling
function onHeaderMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).closest('button')) return
isDragging.value = true
const panel = (e.currentTarget as HTMLElement).closest('.i18n-debug-panel') as HTMLElement
if (panel) {
const rect = panel.getBoundingClientRect()
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
panelPos.value = {
x: Math.max(0, Math.min(e.clientX - dragOffset.value.x, window.innerWidth - 100)),
y: Math.max(0, Math.min(e.clientY - dragOffset.value.y, window.innerHeight - 60)),
}
}
function onMouseUp() {
isDragging.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
// Resize handling
function onResizeMouseDown(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = {
x: e.clientX,
y: e.clientY,
w: panelWidth.value,
h: panelHeight.value,
}
document.addEventListener('mousemove', onResizeMove)
document.addEventListener('mouseup', onResizeUp)
}
function onResizeMove(e: MouseEvent) {
if (!isResizing.value) return
const dx = e.clientX - resizeStart.value.x
const dy = e.clientY - resizeStart.value.y
panelWidth.value = Math.max(320, Math.min(600, resizeStart.value.w + dx))
panelHeight.value = Math.max(280, Math.min(700, resizeStart.value.h + dy))
}
function onResizeUp() {
isResizing.value = false
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeUp)
}
onBeforeUnmount(() => {
clearHighlight()
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', onResizeUp)
})
const panelStyle = computed(() => {
const base: Record<string, string> = {
width: minimized.value ? 'auto' : `${panelWidth.value}px`,
}
if (panelPos.value.x >= 0 && panelPos.value.y >= 0) {
base.left = `${panelPos.value.x}px`
base.top = `${panelPos.value.y}px`
base.right = 'auto'
base.bottom = 'auto'
} else {
base.right = '20px'
base.bottom = '20px'
}
return base
})
const listMaxHeight = computed(() => `${panelHeight.value - 120}px`)
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-3 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-3 scale-95"
>
<div
v-if="debugContext?.panelOpen.value"
tabindex="-1"
class="i18n-debug-panel fixed z-[9998] flex flex-col overflow-hidden rounded-xl border-2 border-solid border-surface-5 bg-surface-2 shadow-2xl outline-none"
:class="{
'cursor-grabbing': isDragging,
'select-none': isDragging || isResizing,
}"
:style="panelStyle"
@keydown="onPanelKeydown"
>
<!-- Resize handle (bottom-right corner) -->
<div
v-if="!minimized"
class="absolute -bottom-0.5 -right-0.5 z-10 h-4 w-4 cursor-se-resize"
@mousedown="onResizeMouseDown"
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
class="absolute bottom-1 right-1 text-secondary/40"
>
<circle cx="8.5" cy="8.5" r="1" fill="currentColor" />
<circle cx="5" cy="8.5" r="1" fill="currentColor" />
<circle cx="8.5" cy="5" r="1" fill="currentColor" />
</svg>
</div>
<!-- Header -->
<div
class="flex items-center gap-2.5 px-3.5 py-2.5 cursor-move select-none border-b border-surface-5/50"
@mousedown="onHeaderMouseDown"
>
<!-- Title group -->
<div class="flex items-center gap-2">
<div class="flex h-6 w-6 items-center justify-center rounded-md bg-brand/10">
<ScanEyeIcon class="h-3.5 w-3.5 text-brand" />
</div>
<span class="text-[13px] font-semibold tracking-tight text-primary">
i18n Inspector
</span>
</div>
<!-- Key count badge -->
<div class="flex items-center gap-1 rounded-full bg-surface-5/50 px-2 py-0.5">
<span class="text-[11px] font-medium tabular-nums text-secondary">
{{ keyCount }} {{ keyCount === 1 ? 'key' : 'keys' }}
</span>
</div>
<!-- Toolbar -->
<div class="ml-auto flex items-center gap-0.5">
<ButtonStyled circular type="transparent">
<button
v-tooltip="
debugContext?.keyReveal.value ? 'Hide keys inline' : 'Reveal keys inline'
"
@click="toggleKeyReveal"
>
<component :is="debugContext?.keyReveal.value ? EyeOffIcon : EyeIcon" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button v-tooltip="'Toggle CSS debug overlay'" @click="toggleOverlay">
<ScanEyeIcon />
</button>
</ButtonStyled>
<div class="mx-0.5 h-4 w-px bg-surface-5/60" />
<ButtonStyled circular type="transparent">
<button
v-tooltip="minimized ? 'Expand panel' : 'Minimize panel'"
@click="minimized = !minimized"
>
<component :is="minimized ? MaximizeIcon : MinusIcon" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button v-tooltip="'Close inspector'" @click="closePanel">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<!-- Body (hidden when minimized) -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[600px]"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 max-h-[600px]"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="!minimized" class="flex flex-col overflow-hidden w-full">
<!-- Search -->
<div class="px-3 py-2.5 !w-full">
<StyledInput
ref="searchInputRef"
v-model="searchQuery"
placeholder="Search keys or values..."
clearable
:icon="SearchIcon"
size="small"
wrapper-class="w-full"
/>
</div>
<!-- Entry list -->
<div
ref="listContainerRef"
class="overflow-y-auto overscroll-contain scroll-smooth"
:style="{ maxHeight: listMaxHeight }"
>
<TransitionGroup
move-class="transition-transform duration-200"
enter-active-class="transition-all duration-150 ease-out"
enter-from-class="opacity-0 -translate-x-2"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-100 ease-in absolute w-full"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-for="(entry, index) in filteredEntries"
:key="entry.key"
class="group relative flex items-center gap-2.5 px-3.5 py-2 transition-colors cursor-pointer"
:class="[activeEntryIndex === index ? 'bg-brand/8' : 'hover:bg-surface-5/40']"
:data-active="activeEntryIndex === index"
@mouseenter="
() => {
highlightElement(entry.key)
activeEntryIndex = index
}
"
@mouseleave="clearHighlight"
@click="copyKey(entry.key)"
>
<!-- Active indicator -->
<div
v-if="activeEntryIndex === index"
class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-brand transition-all"
/>
<!-- Entry content -->
<div class="min-w-0 flex-1">
<div
class="font-mono text-[12px] leading-relaxed text-primary truncate"
:title="entry.key"
v-html="highlightMatch(entry.key, searchQuery)"
/>
<div
class="mt-0.5 text-[11px] leading-relaxed text-secondary truncate"
:title="entry.value"
v-html="highlightMatch(truncate(entry.value, 50), searchQuery)"
/>
</div>
<!-- Actions -->
<div class="flex shrink-0 items-center gap-1">
<!-- Copied feedback -->
<Transition
enter-active-class="transition-all duration-150 ease-out"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0 scale-90"
>
<span
v-if="copiedKey === entry.key"
class="flex items-center gap-1 rounded-md bg-green/10 px-1.5 py-0.5 text-[10px] font-medium text-green"
>
<svg width="10" height="10" viewBox="0 0 16 16" fill="none">
<path
d="M3 8.5L6.5 12L13 4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Copied
</span>
</Transition>
<!-- Copy hint (shown on hover when not copied) -->
<span
v-if="copiedKey !== entry.key"
class="text-[10px] text-secondary/0 transition-colors group-hover:text-secondary/60"
>
click to copy
</span>
</div>
</div>
</TransitionGroup>
<!-- Empty state -->
<div
v-if="filteredEntries.length === 0"
class="flex flex-col items-center justify-center px-4 py-10"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-surface-5/40">
<SearchIcon class="h-4 w-4 text-secondary/60" />
</div>
<p class="mt-3 text-[13px] font-medium text-primary">
{{ searchQuery ? 'No matches found' : 'No keys registered' }}
</p>
<p class="mt-1 text-[11px] text-secondary">
{{
searchQuery
? 'Try a different search term'
: 'Navigate the app to discover i18n keys'
}}
</p>
</div>
</div>
<!-- Footer status bar -->
<div class="flex items-center justify-between border-t border-surface-5/50 px-3.5 py-2">
<div class="flex items-center gap-2">
<div class="h-1.5 w-1.5 rounded-full bg-green animate-pulse" />
<span class="text-[11px] text-secondary"> Watching </span>
</div>
<div class="flex items-center gap-3">
<span class="text-[10px] text-secondary/60">
<kbd
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
>&uarr;</kbd
>
<kbd
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
>&darr;</kbd
>
navigate
</span>
<span class="text-[10px] text-secondary/60">
<kbd
class="rounded border border-surface-5/40 bg-surface-3/60 px-1 py-px text-[10px]"
>&crarr;</kbd
>
copy
</span>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.i18n-debug-panel {
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-webkit-font-smoothing: antialiased;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.03),
0 2px 4px rgba(0, 0, 0, 0.04),
0 12px 24px rgba(0, 0, 0, 0.12),
0 24px 48px rgba(0, 0, 0, 0.06);
}
/* Custom scrollbar */
.i18n-debug-panel ::-webkit-scrollbar {
width: 6px;
}
.i18n-debug-panel ::-webkit-scrollbar-track {
background: transparent;
}
.i18n-debug-panel ::-webkit-scrollbar-thumb {
background: var(--surface-5);
border-radius: 3px;
}
.i18n-debug-panel ::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* Animate the pulse indicator */
@keyframes soft-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.animate-pulse {
animation: soft-pulse 2s ease-in-out infinite;
}
</style>

View File

@@ -3,6 +3,7 @@ import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from '
import { computed, markRaw, useSlots, type VNode } from 'vue'
import type { MessageDescriptor } from '../../composables/i18n'
import { injectI18nDebug } from '../../composables/i18n-debug'
import { injectI18n } from '../../providers/i18n'
const props = defineProps<{
@@ -12,6 +13,10 @@ const props = defineProps<{
const slots = useSlots()
const { t, locale } = injectI18n()
const debugContext = injectI18nDebug()
const debugEnabled = computed(() => debugContext?.enabled.value ?? false)
const debugKeyReveal = computed(() => debugContext?.keyReveal.value ?? false)
const formattedParts = computed(() => {
const key = props.messageId.id
@@ -24,6 +29,18 @@ const formattedParts = computed(() => {
msg = props.messageId.defaultMessage ?? key
}
if (debugEnabled.value) {
debugContext!.registry.set(key, {
key,
value: msg,
defaultMessage: props.messageId.defaultMessage,
timestamp: Date.now(),
})
if (debugKeyReveal.value) {
return [`\u300C${key}\u300D`]
}
}
const slotHandlers: Record<string, FormatXMLElementFn<VNode>> = {}
const slotNames = Object.keys(slots)
@@ -69,7 +86,17 @@ const formattedParts = computed(() => {
</script>
<template>
<template v-for="(part, index) in formattedParts" :key="index">
<span
v-if="debugEnabled && !debugKeyReveal"
:data-i18n-key="messageId.id"
style="display: contents"
>
<template v-for="(part, index) in formattedParts" :key="index">
<component :is="() => part" v-if="typeof part === 'object'" />
<template v-else>{{ part }}</template>
</template>
</span>
<template v-for="(part, index) in formattedParts" v-else :key="index">
<component :is="() => part" v-if="typeof part === 'object'" />
<template v-else>{{ part }}</template>
</template>

View File

@@ -20,8 +20,8 @@
<!-- Multiline textarea -->
<textarea
v-if="multiline"
ref="inputRef"
:id="id"
ref="inputRef"
:value="model"
:placeholder="placeholder"
:disabled="disabled"
@@ -46,8 +46,8 @@
<!-- Single-line input -->
<input
v-else
ref="inputRef"
:id="id"
ref="inputRef"
:type="type"
:value="model"
:placeholder="placeholder"

View File

@@ -31,6 +31,7 @@ export { default as FloatingPanel } from './FloatingPanel.vue'
export { default as FormattedTag } from './FormattedTag.vue'
export { default as HeadingLink } from './HeadingLink.vue'
export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as I18nDebugPanel } from './I18nDebugPanel.vue'
export { default as IconSelect } from './IconSelect.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'