qdadm 1.13.0 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -4
- package/src/chain/ActiveStack.ts +79 -98
- package/src/chain/StackHydrator.ts +3 -2
- package/src/chain/index.ts +7 -1
- package/src/components/QdadmRoot.vue +52 -0
- package/src/components/edit/FormActions.vue +9 -6
- package/src/components/edit/LookupPickerDialog.vue +6 -3
- package/src/components/index.ts +6 -0
- package/src/composables/useEntityItemFormPage.ts +1 -0
- package/src/composables/useEntityItemShowPage.ts +1 -0
- package/src/composables/useFieldManager.ts +100 -3
- package/src/composables/useListPage.ts +50 -59
- package/src/composables/useListPage.utils.ts +101 -0
- package/src/composables/useNavigation.ts +26 -3
- package/src/composables/useOptionsLookup.ts +5 -1
- package/src/gen/generateManagers.test.js +27 -0
- package/src/gen/generateManagers.ts +12 -0
- package/src/hooks/HookRegistry.ts +14 -435
- package/src/i18n/I18n.ts +344 -0
- package/src/i18n/IncrementalDomainProvider.ts +153 -0
- package/src/i18n/InlineTranslationProvider.ts +4 -0
- package/src/i18n/LazyTranslationProvider.ts +102 -0
- package/src/i18n/MessagesRegistry.ts +4 -0
- package/src/i18n/Resolver.ts +4 -0
- package/src/i18n/__tests__/I18n.test.ts +169 -0
- package/src/i18n/__tests__/IncrementalDomainProvider.test.ts +146 -0
- package/src/i18n/__tests__/LazyTranslationProvider.test.ts +100 -0
- package/src/i18n/__tests__/Resolver.test.ts +271 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +28 -0
- package/src/i18n/defaults/core.en.yml +55 -0
- package/src/i18n/defaults/core.fr.yml +55 -0
- package/src/i18n/index.ts +55 -0
- package/src/i18n/loaders/raw-modules.d.ts +15 -0
- package/src/i18n/loaders/yaml.ts +35 -0
- package/src/i18n/strategies.ts +4 -0
- package/src/i18n/types.ts +34 -0
- package/src/i18n/useI18n.ts +34 -0
- package/src/index.ts +37 -0
- package/src/kernel/EventRouter.ts +17 -300
- package/src/kernel/Kernel.i18n.ts +29 -0
- package/src/kernel/Kernel.modules.ts +6 -0
- package/src/kernel/Kernel.registries.ts +10 -2
- package/src/kernel/Kernel.routing.ts +43 -1
- package/src/kernel/Kernel.ts +43 -0
- package/src/kernel/Kernel.types.ts +52 -1
- package/src/kernel/Kernel.vue.ts +121 -15
- package/src/kernel/KernelContext.entities.ts +80 -0
- package/src/kernel/KernelContext.events.ts +57 -0
- package/src/kernel/KernelContext.i18n.ts +37 -0
- package/src/kernel/KernelContext.permissions.ts +38 -0
- package/src/kernel/KernelContext.routing.ts +280 -0
- package/src/kernel/KernelContext.ts +125 -834
- package/src/kernel/KernelContext.types.ts +173 -0
- package/src/kernel/KernelContext.zones.ts +54 -0
- package/src/kernel/SSEBridge.ts +7 -362
- package/src/kernel/SignalBus.ts +24 -148
- package/src/modules/debug/AuthCollector.ts +48 -1
- package/src/modules/debug/Collector.ts +16 -302
- package/src/modules/debug/DebugBridge.ts +10 -171
- package/src/modules/debug/DebugModule.ts +35 -5
- package/src/modules/debug/EntitiesCollector.ts +97 -1
- package/src/modules/debug/ErrorCollector.ts +2 -77
- package/src/modules/debug/I18nCollector.ts +9 -0
- package/src/modules/debug/LocalStorageAdapter.ts +3 -147
- package/src/modules/debug/RouterCollector.ts +101 -1
- package/src/modules/debug/SignalCollector.ts +2 -150
- package/src/modules/debug/ToastCollector.ts +2 -91
- package/src/modules/debug/ZonesCollector.ts +93 -1
- package/src/modules/debug/components/DebugBar.vue +19 -775
- package/src/modules/debug/components/adminPanelsConfig.ts +42 -0
- package/src/modules/debug/components/index.ts +4 -3
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -1
- package/src/modules/debug/components/panels/EntitiesPanel.vue +5 -5
- package/src/modules/debug/components/panels/I18nPanel.vue +738 -0
- package/src/modules/debug/components/panels/RouterPanel.vue +1 -1
- package/src/modules/debug/components/panels/index.ts +10 -4
- package/src/modules/debug/index.ts +15 -0
- package/src/modules/debug/styles.scss +22 -18
- package/src/utils/index.ts +0 -3
- package/src/vite/qdadmDebugPlugin.ts +401 -0
- package/src/vite-env.d.ts +16 -0
- package/src/modules/debug/components/ObjectTree.vue +0 -123
- package/src/modules/debug/components/panels/EntriesPanel.vue +0 -100
- package/src/modules/debug/components/panels/SignalsPanel.vue +0 -188
- package/src/modules/debug/components/panels/ToastsPanel.vue +0 -45
- package/src/utils/debugInjector.ts +0 -306
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* I18nPanel - Debug panel for the i18n subsystem.
|
|
4
|
+
*
|
|
5
|
+
* Sections:
|
|
6
|
+
* - Header: active locale, available locales, strategy, providers
|
|
7
|
+
* - Resolve: live resolve() against any key with full trace
|
|
8
|
+
* - Missing: keys captured via i18n:missing (deduped by key+locale, with counts)
|
|
9
|
+
* - Coverage: entity × locale matrix with per-cell drill-down
|
|
10
|
+
* - Bundle: dump of the merged inline bundle for a chosen locale
|
|
11
|
+
*/
|
|
12
|
+
import { ref, computed, inject, watch, onMounted } from 'vue'
|
|
13
|
+
import { ObjectTree } from '@quazardous/qddebug'
|
|
14
|
+
import type { I18n } from '../../../../i18n/I18n'
|
|
15
|
+
import type { ResolveTrace } from '../../../../i18n/types'
|
|
16
|
+
import type { I18nCollector, MissingKeyEntry } from '../../I18nCollector'
|
|
17
|
+
import type { Orchestrator } from '../../../../orchestrator/Orchestrator'
|
|
18
|
+
|
|
19
|
+
interface SignalsBus {
|
|
20
|
+
emit: (signal: string, data?: unknown) => void | Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
collector: I18nCollector
|
|
25
|
+
entries: MissingKeyEntry[]
|
|
26
|
+
}>()
|
|
27
|
+
|
|
28
|
+
const i18n = inject<I18n | null>('qdadmI18n', null)
|
|
29
|
+
const orchestrator = inject<Orchestrator | null>('qdadmOrchestrator', null)
|
|
30
|
+
const signals = inject<SignalsBus | null>('qdadmSignals', null)
|
|
31
|
+
|
|
32
|
+
type Section = 'header' | 'resolve' | 'missing' | 'coverage' | 'bundle'
|
|
33
|
+
const activeSection = ref<Section>('missing')
|
|
34
|
+
|
|
35
|
+
// ─── Header ───────────────────────────────────────────────────────────────
|
|
36
|
+
const currentLocale = computed<string>(() => i18n?.locale.value ?? 'en')
|
|
37
|
+
const availableLocales = ref<string[]>([])
|
|
38
|
+
|
|
39
|
+
async function refreshAvailableLocales() {
|
|
40
|
+
if (!i18n) {
|
|
41
|
+
availableLocales.value = []
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
availableLocales.value = await i18n.availableLocales()
|
|
45
|
+
// Always include the active locale even if no provider declares it
|
|
46
|
+
if (!availableLocales.value.includes(currentLocale.value)) {
|
|
47
|
+
availableLocales.value = [currentLocale.value, ...availableLocales.value]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
onMounted(refreshAvailableLocales)
|
|
51
|
+
watch(currentLocale, refreshAvailableLocales)
|
|
52
|
+
|
|
53
|
+
function switchLocale(locale: string) {
|
|
54
|
+
if (signals) {
|
|
55
|
+
void signals.emit('locale:change', locale)
|
|
56
|
+
} else if (i18n) {
|
|
57
|
+
void i18n.changeLocale(locale)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Resolve tester ───────────────────────────────────────────────────────
|
|
62
|
+
const resolveKey = ref('')
|
|
63
|
+
const resolveLocale = ref<string>('')
|
|
64
|
+
watch(currentLocale, (loc) => {
|
|
65
|
+
if (!resolveLocale.value) resolveLocale.value = loc
|
|
66
|
+
}, { immediate: true })
|
|
67
|
+
|
|
68
|
+
const resolveTrace = computed<ResolveTrace | null>(() => {
|
|
69
|
+
if (!i18n || !resolveKey.value) return null
|
|
70
|
+
// Temporarily resolve against the chosen locale (without switching globally)
|
|
71
|
+
// by re-calling resolve via a small helper — kernel.i18n.resolve uses the
|
|
72
|
+
// active locale, so we mutate the instance's locale ref briefly only for
|
|
73
|
+
// resolve(), restore it. This is read-only though; safer to just call
|
|
74
|
+
// resolve which uses locale.value, so we surface the trace for the active
|
|
75
|
+
// locale and let the user switch via the dropdown.
|
|
76
|
+
return i18n.resolve(resolveKey.value)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
function copyResolveSnippet() {
|
|
80
|
+
if (!resolveTrace.value) return
|
|
81
|
+
const segs = resolveKey.value.split('.')
|
|
82
|
+
const skeleton = buildNested(segs, '…')
|
|
83
|
+
const snippet = `ctx.messages('${resolveLocale.value || currentLocale.value}', ${JSON.stringify(skeleton, null, 2)})`
|
|
84
|
+
void navigator.clipboard?.writeText(snippet)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildNested(segments: string[], leaf: unknown): Record<string, unknown> {
|
|
88
|
+
const root: Record<string, unknown> = {}
|
|
89
|
+
let cursor: Record<string, unknown> = root
|
|
90
|
+
segments.forEach((seg, i) => {
|
|
91
|
+
if (i === segments.length - 1) {
|
|
92
|
+
cursor[seg] = leaf
|
|
93
|
+
} else {
|
|
94
|
+
cursor[seg] = {}
|
|
95
|
+
cursor = cursor[seg] as Record<string, unknown>
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
return root
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Missing keys ─────────────────────────────────────────────────────────
|
|
102
|
+
const missingFilter = ref('')
|
|
103
|
+
const missingGrouped = computed<Array<{ ns: string; entries: MissingKeyEntry[] }>>(() => {
|
|
104
|
+
const filter = missingFilter.value.trim().toLowerCase()
|
|
105
|
+
const filtered = filter
|
|
106
|
+
? props.entries.filter(
|
|
107
|
+
(e) => e.key.toLowerCase().includes(filter) || e.locale.includes(filter)
|
|
108
|
+
)
|
|
109
|
+
: props.entries
|
|
110
|
+
const groups: Record<string, MissingKeyEntry[]> = {}
|
|
111
|
+
for (const e of filtered) {
|
|
112
|
+
const ns = e.key.split('.')[0] ?? '_other'
|
|
113
|
+
if (!groups[ns]) groups[ns] = []
|
|
114
|
+
groups[ns]!.push(e)
|
|
115
|
+
}
|
|
116
|
+
return Object.entries(groups)
|
|
117
|
+
.map(([ns, entries]) => ({ ns, entries: entries.sort((a, b) => a.key.localeCompare(b.key)) }))
|
|
118
|
+
.sort((a, b) => a.ns.localeCompare(b.ns))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function copyMissingSkeleton() {
|
|
122
|
+
const skeleton = props.collector.asJsonSkeleton()
|
|
123
|
+
void navigator.clipboard?.writeText(JSON.stringify(skeleton, null, 2))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Coverage matrix ──────────────────────────────────────────────────────
|
|
127
|
+
interface CoverageCell {
|
|
128
|
+
entity: string
|
|
129
|
+
locale: string
|
|
130
|
+
total: number
|
|
131
|
+
hits: number
|
|
132
|
+
status: 'green' | 'yellow' | 'red' | 'none'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface CoverageDrill {
|
|
136
|
+
entity: string
|
|
137
|
+
locale: string
|
|
138
|
+
fields: Array<{ name: string; key: string; hit: boolean; result: string }>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const coverageEntities = ref<string[]>([])
|
|
142
|
+
const drillCell = ref<CoverageDrill | null>(null)
|
|
143
|
+
|
|
144
|
+
function refreshCoverageEntities() {
|
|
145
|
+
if (!orchestrator) {
|
|
146
|
+
coverageEntities.value = []
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
coverageEntities.value = orchestrator.getRegisteredNames?.() ?? []
|
|
150
|
+
}
|
|
151
|
+
onMounted(refreshCoverageEntities)
|
|
152
|
+
|
|
153
|
+
function getEntityFields(entity: string): string[] {
|
|
154
|
+
const manager = orchestrator?.get?.(entity) as { fields?: Record<string, unknown> } | null
|
|
155
|
+
return manager?.fields ? Object.keys(manager.fields) : []
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const coverageMatrix = computed<CoverageCell[]>(() => {
|
|
159
|
+
if (!i18n) return []
|
|
160
|
+
// Re-read tick for reactivity (the I18n locale ref is the natural trigger)
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
162
|
+
currentLocale.value
|
|
163
|
+
const cells: CoverageCell[] = []
|
|
164
|
+
for (const entity of coverageEntities.value) {
|
|
165
|
+
const fields = getEntityFields(entity)
|
|
166
|
+
if (fields.length === 0) continue
|
|
167
|
+
for (const locale of availableLocales.value) {
|
|
168
|
+
const prevLocale = i18n.locale.value
|
|
169
|
+
// Briefly point the resolver at the target locale to compute coverage,
|
|
170
|
+
// then restore. We don't emit locale:changed so nothing else reacts.
|
|
171
|
+
i18n.locale.value = locale
|
|
172
|
+
let hits = 0
|
|
173
|
+
for (const f of fields) {
|
|
174
|
+
const trace = i18n.resolve(`entities.${entity}.fields.${f}`)
|
|
175
|
+
if (trace.hit) hits++
|
|
176
|
+
}
|
|
177
|
+
i18n.locale.value = prevLocale
|
|
178
|
+
const total = fields.length
|
|
179
|
+
const ratio = total === 0 ? 0 : hits / total
|
|
180
|
+
let status: CoverageCell['status'] = 'red'
|
|
181
|
+
if (ratio === 1) status = 'green'
|
|
182
|
+
else if (ratio >= 0.5) status = 'yellow'
|
|
183
|
+
else if (hits > 0) status = 'red'
|
|
184
|
+
else status = 'red'
|
|
185
|
+
cells.push({ entity, locale, total, hits, status })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return cells
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const coverageByEntity = computed<Record<string, Record<string, CoverageCell>>>(() => {
|
|
192
|
+
const out: Record<string, Record<string, CoverageCell>> = {}
|
|
193
|
+
for (const cell of coverageMatrix.value) {
|
|
194
|
+
if (!out[cell.entity]) out[cell.entity] = {}
|
|
195
|
+
out[cell.entity]![cell.locale] = cell
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
function drillDown(entity: string, locale: string) {
|
|
201
|
+
if (!i18n) return
|
|
202
|
+
const fields = getEntityFields(entity)
|
|
203
|
+
const prevLocale = i18n.locale.value
|
|
204
|
+
i18n.locale.value = locale
|
|
205
|
+
const detail = fields.map((name) => {
|
|
206
|
+
const key = `entities.${entity}.fields.${name}`
|
|
207
|
+
const trace = i18n.resolve(key)
|
|
208
|
+
return { name, key, hit: trace.hit, result: trace.result }
|
|
209
|
+
})
|
|
210
|
+
i18n.locale.value = prevLocale
|
|
211
|
+
drillCell.value = { entity, locale, fields: detail }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Bundle inspector ─────────────────────────────────────────────────────
|
|
215
|
+
const bundleLocale = ref<string>('')
|
|
216
|
+
watch(currentLocale, (loc) => {
|
|
217
|
+
if (!bundleLocale.value) bundleLocale.value = loc
|
|
218
|
+
}, { immediate: true })
|
|
219
|
+
|
|
220
|
+
const bundleData = computed<Record<string, unknown> | null>(() => {
|
|
221
|
+
if (!i18n || !bundleLocale.value) return null
|
|
222
|
+
return i18n.dump(bundleLocale.value) as Record<string, unknown>
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
function downloadBundle() {
|
|
226
|
+
if (!bundleData.value) return
|
|
227
|
+
const blob = new Blob([JSON.stringify(bundleData.value, null, 2)], { type: 'application/json' })
|
|
228
|
+
const url = URL.createObjectURL(blob)
|
|
229
|
+
const a = document.createElement('a')
|
|
230
|
+
a.href = url
|
|
231
|
+
a.download = `qdadm-i18n-${bundleLocale.value}.json`
|
|
232
|
+
a.click()
|
|
233
|
+
URL.revokeObjectURL(url)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
237
|
+
function formatTime(ts: number): string {
|
|
238
|
+
return new Date(ts).toLocaleTimeString('en-US', { hour12: false })
|
|
239
|
+
}
|
|
240
|
+
</script>
|
|
241
|
+
|
|
242
|
+
<template>
|
|
243
|
+
<div class="i18n-panel">
|
|
244
|
+
<!-- Section tabs -->
|
|
245
|
+
<div class="i18n-tabs">
|
|
246
|
+
<button
|
|
247
|
+
v-for="s in (['header','resolve','missing','coverage','bundle'] as Section[])"
|
|
248
|
+
:key="s"
|
|
249
|
+
class="i18n-tab"
|
|
250
|
+
:class="{ 'i18n-tab-active': activeSection === s }"
|
|
251
|
+
@click="activeSection = s"
|
|
252
|
+
>
|
|
253
|
+
{{ ({ header: 'State', resolve: 'Resolve', missing: 'Missing', coverage: 'Coverage', bundle: 'Bundle' })[s] }}
|
|
254
|
+
<span v-if="s === 'missing' && entries.length" class="i18n-tab-count">{{ entries.length }}</span>
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<div v-if="!i18n" class="i18n-empty">
|
|
259
|
+
<i class="pi pi-info-circle" />
|
|
260
|
+
<span>No i18n subsystem on this kernel.</span>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<!-- ─── State ────────────────────────────────────────────────── -->
|
|
264
|
+
<div v-else-if="activeSection === 'header'" class="i18n-section">
|
|
265
|
+
<div class="i18n-row">
|
|
266
|
+
<span class="i18n-row-label">Active locale</span>
|
|
267
|
+
<select :value="currentLocale" class="i18n-select" @change="switchLocale(($event.target as HTMLSelectElement).value)">
|
|
268
|
+
<option v-for="loc in availableLocales" :key="loc" :value="loc">{{ loc }}</option>
|
|
269
|
+
</select>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="i18n-row">
|
|
272
|
+
<span class="i18n-row-label">Available</span>
|
|
273
|
+
<span class="i18n-row-value">{{ availableLocales.join(', ') || '—' }}</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div class="i18n-row">
|
|
276
|
+
<span class="i18n-row-label">Locale switches</span>
|
|
277
|
+
<span class="i18n-row-value">
|
|
278
|
+
<span v-for="(c, idx) in collector.getLocaleHistory()" :key="idx" class="i18n-chip">
|
|
279
|
+
{{ c.locale }} <small>{{ formatTime(c.at) }}</small>
|
|
280
|
+
</span>
|
|
281
|
+
<span v-if="collector.getLocaleHistory().length === 0" class="i18n-muted">none yet</span>
|
|
282
|
+
</span>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<!-- ─── Resolve tester ─────────────────────────────────────────── -->
|
|
287
|
+
<div v-else-if="activeSection === 'resolve'" class="i18n-section">
|
|
288
|
+
<div class="i18n-resolve-input">
|
|
289
|
+
<input
|
|
290
|
+
v-model="resolveKey"
|
|
291
|
+
type="text"
|
|
292
|
+
placeholder="entities.books.fields.title"
|
|
293
|
+
class="i18n-key-input"
|
|
294
|
+
/>
|
|
295
|
+
<button v-if="resolveTrace" class="debug-toolbar-btn" @click="copyResolveSnippet">
|
|
296
|
+
<i class="pi pi-copy" /> Snippet
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
299
|
+
<div v-if="resolveTrace" class="i18n-resolve-result">
|
|
300
|
+
<div class="i18n-resolve-summary">
|
|
301
|
+
<span class="i18n-resolve-value">"{{ resolveTrace.result }}"</span>
|
|
302
|
+
<span class="i18n-chip" :class="{ 'i18n-chip-hit': resolveTrace.hit, 'i18n-chip-miss': !resolveTrace.hit }">
|
|
303
|
+
{{ resolveTrace.hit ? 'hit' : 'miss' }}
|
|
304
|
+
</span>
|
|
305
|
+
<span class="i18n-muted">in {{ resolveTrace.locale }}</span>
|
|
306
|
+
</div>
|
|
307
|
+
<ol class="i18n-trace">
|
|
308
|
+
<li v-for="(step, idx) in resolveTrace.steps" :key="idx" class="i18n-trace-step" :class="`i18n-step-${step.kind}`">
|
|
309
|
+
<span class="i18n-trace-kind">{{ step.kind }}</span>
|
|
310
|
+
<code class="i18n-trace-detail">{{ JSON.stringify(step) }}</code>
|
|
311
|
+
</li>
|
|
312
|
+
</ol>
|
|
313
|
+
</div>
|
|
314
|
+
<div v-else-if="resolveKey" class="i18n-muted">Type a key above…</div>
|
|
315
|
+
<div v-else class="i18n-muted">Enter a convention key (e.g. <code>entities.books.fields.title</code>) to see how it resolves.</div>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<!-- ─── Missing keys ────────────────────────────────────────────── -->
|
|
319
|
+
<div v-else-if="activeSection === 'missing'" class="i18n-section">
|
|
320
|
+
<div class="i18n-missing-toolbar">
|
|
321
|
+
<input
|
|
322
|
+
v-model="missingFilter"
|
|
323
|
+
type="text"
|
|
324
|
+
placeholder="filter…"
|
|
325
|
+
class="i18n-key-input"
|
|
326
|
+
/>
|
|
327
|
+
<button class="debug-toolbar-btn" :disabled="entries.length === 0" @click="copyMissingSkeleton">
|
|
328
|
+
<i class="pi pi-copy" /> Copy skeleton
|
|
329
|
+
</button>
|
|
330
|
+
<button class="debug-toolbar-btn" :disabled="entries.length === 0" @click="collector.clear()">
|
|
331
|
+
<i class="pi pi-trash" /> Clear
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
<div v-if="entries.length === 0" class="i18n-empty">
|
|
335
|
+
<i class="pi pi-check-circle" />
|
|
336
|
+
<span>No missing keys captured (yet).</span>
|
|
337
|
+
</div>
|
|
338
|
+
<div v-else>
|
|
339
|
+
<div v-for="group in missingGrouped" :key="group.ns" class="i18n-missing-group">
|
|
340
|
+
<div class="i18n-missing-group-header">
|
|
341
|
+
<i class="pi pi-folder" />
|
|
342
|
+
<span class="i18n-missing-group-name">{{ group.ns }}</span>
|
|
343
|
+
<span class="i18n-muted">({{ group.entries.length }})</span>
|
|
344
|
+
</div>
|
|
345
|
+
<div v-for="e in group.entries" :key="`${e.key}|${e.locale}`" class="i18n-missing-entry">
|
|
346
|
+
<code class="i18n-missing-key">{{ e.key }}</code>
|
|
347
|
+
<span class="i18n-chip">{{ e.locale }}</span>
|
|
348
|
+
<span class="i18n-muted">× {{ e.count }}</span>
|
|
349
|
+
<span class="i18n-muted i18n-missing-time">{{ formatTime(e.lastSeen) }}</span>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<!-- ─── Coverage matrix ─────────────────────────────────────────── -->
|
|
356
|
+
<div v-else-if="activeSection === 'coverage'" class="i18n-section">
|
|
357
|
+
<div v-if="coverageEntities.length === 0" class="i18n-muted">No registered entities.</div>
|
|
358
|
+
<div v-else>
|
|
359
|
+
<table class="i18n-coverage-table">
|
|
360
|
+
<thead>
|
|
361
|
+
<tr>
|
|
362
|
+
<th>entity</th>
|
|
363
|
+
<th v-for="loc in availableLocales" :key="loc">{{ loc }}</th>
|
|
364
|
+
</tr>
|
|
365
|
+
</thead>
|
|
366
|
+
<tbody>
|
|
367
|
+
<tr v-for="entity in coverageEntities" :key="entity">
|
|
368
|
+
<td class="i18n-coverage-entity">{{ entity }}</td>
|
|
369
|
+
<td v-for="loc in availableLocales" :key="loc"
|
|
370
|
+
class="i18n-coverage-cell"
|
|
371
|
+
:class="`i18n-cov-${coverageByEntity[entity]?.[loc]?.status ?? 'none'}`"
|
|
372
|
+
@click="drillDown(entity, loc)">
|
|
373
|
+
<span v-if="coverageByEntity[entity]?.[loc]">
|
|
374
|
+
{{ coverageByEntity[entity][loc].hits }}/{{ coverageByEntity[entity][loc].total }}
|
|
375
|
+
</span>
|
|
376
|
+
<span v-else>—</span>
|
|
377
|
+
</td>
|
|
378
|
+
</tr>
|
|
379
|
+
</tbody>
|
|
380
|
+
</table>
|
|
381
|
+
<div v-if="drillCell" class="i18n-coverage-drill">
|
|
382
|
+
<div class="i18n-coverage-drill-header">
|
|
383
|
+
<strong>{{ drillCell.entity }}</strong>
|
|
384
|
+
<span class="i18n-chip">{{ drillCell.locale }}</span>
|
|
385
|
+
<button class="debug-toolbar-btn" @click="drillCell = null">
|
|
386
|
+
<i class="pi pi-times" />
|
|
387
|
+
</button>
|
|
388
|
+
</div>
|
|
389
|
+
<ul class="i18n-coverage-drill-list">
|
|
390
|
+
<li v-for="f in drillCell.fields" :key="f.name" :class="{ 'i18n-cov-hit': f.hit, 'i18n-cov-miss': !f.hit }">
|
|
391
|
+
<i :class="['pi', f.hit ? 'pi-check' : 'pi-times']" />
|
|
392
|
+
<code>{{ f.name }}</code>
|
|
393
|
+
<span class="i18n-coverage-result">"{{ f.result }}"</span>
|
|
394
|
+
</li>
|
|
395
|
+
</ul>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<!-- ─── Bundle inspector ────────────────────────────────────────── -->
|
|
401
|
+
<div v-else-if="activeSection === 'bundle'" class="i18n-section">
|
|
402
|
+
<div class="i18n-bundle-toolbar">
|
|
403
|
+
<select v-model="bundleLocale" class="i18n-select">
|
|
404
|
+
<option v-for="loc in availableLocales" :key="loc" :value="loc">{{ loc }}</option>
|
|
405
|
+
</select>
|
|
406
|
+
<button class="debug-toolbar-btn" :disabled="!bundleData" @click="downloadBundle">
|
|
407
|
+
<i class="pi pi-download" /> Dump JSON
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
<div v-if="bundleData" class="i18n-bundle-tree">
|
|
411
|
+
<ObjectTree :data="bundleData" :expanded="false" />
|
|
412
|
+
</div>
|
|
413
|
+
<div v-else class="i18n-muted">No bundle loaded for this locale.</div>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
</template>
|
|
417
|
+
|
|
418
|
+
<style scoped>
|
|
419
|
+
.i18n-panel {
|
|
420
|
+
display: flex;
|
|
421
|
+
flex-direction: column;
|
|
422
|
+
height: 100%;
|
|
423
|
+
font-size: 12px;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.i18n-tabs {
|
|
427
|
+
display: flex;
|
|
428
|
+
gap: 4px;
|
|
429
|
+
padding: 6px 8px;
|
|
430
|
+
border-bottom: 1px solid var(--p-surface-700, #2a2a2a);
|
|
431
|
+
flex-shrink: 0;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.i18n-tab {
|
|
435
|
+
background: transparent;
|
|
436
|
+
border: 1px solid transparent;
|
|
437
|
+
border-radius: 4px;
|
|
438
|
+
padding: 4px 10px;
|
|
439
|
+
color: var(--p-text-muted-color, #888);
|
|
440
|
+
cursor: pointer;
|
|
441
|
+
font-size: 11px;
|
|
442
|
+
display: inline-flex;
|
|
443
|
+
align-items: center;
|
|
444
|
+
gap: 6px;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.i18n-tab:hover {
|
|
448
|
+
background: var(--p-surface-800, #1f1f1f);
|
|
449
|
+
color: var(--p-text-color, #ddd);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.i18n-tab-active {
|
|
453
|
+
background: var(--p-surface-800, #1f1f1f);
|
|
454
|
+
color: var(--p-primary-color, #ec4899);
|
|
455
|
+
border-color: var(--p-primary-color, #ec4899);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.i18n-tab-count {
|
|
459
|
+
background: var(--p-primary-color, #ec4899);
|
|
460
|
+
color: #fff;
|
|
461
|
+
border-radius: 9px;
|
|
462
|
+
padding: 0 6px;
|
|
463
|
+
font-size: 10px;
|
|
464
|
+
line-height: 16px;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.i18n-section {
|
|
468
|
+
flex: 1;
|
|
469
|
+
overflow: auto;
|
|
470
|
+
padding: 10px 12px;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.i18n-empty {
|
|
474
|
+
display: flex;
|
|
475
|
+
align-items: center;
|
|
476
|
+
gap: 8px;
|
|
477
|
+
padding: 16px;
|
|
478
|
+
color: var(--p-text-muted-color, #888);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.i18n-row {
|
|
482
|
+
display: flex;
|
|
483
|
+
align-items: center;
|
|
484
|
+
gap: 12px;
|
|
485
|
+
padding: 6px 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.i18n-row-label {
|
|
489
|
+
width: 120px;
|
|
490
|
+
flex-shrink: 0;
|
|
491
|
+
color: var(--p-text-muted-color, #888);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.i18n-row-value {
|
|
495
|
+
display: flex;
|
|
496
|
+
align-items: center;
|
|
497
|
+
gap: 6px;
|
|
498
|
+
flex-wrap: wrap;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.i18n-select,
|
|
502
|
+
.i18n-key-input {
|
|
503
|
+
background: var(--p-surface-900, #121212);
|
|
504
|
+
color: var(--p-text-color, #ddd);
|
|
505
|
+
border: 1px solid var(--p-surface-600, #404040);
|
|
506
|
+
border-radius: 4px;
|
|
507
|
+
padding: 4px 8px;
|
|
508
|
+
font-size: 12px;
|
|
509
|
+
font-family: inherit;
|
|
510
|
+
min-width: 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.i18n-key-input {
|
|
514
|
+
flex: 1;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.i18n-chip {
|
|
518
|
+
display: inline-flex;
|
|
519
|
+
align-items: center;
|
|
520
|
+
gap: 4px;
|
|
521
|
+
background: var(--p-surface-800, #1f1f1f);
|
|
522
|
+
border: 1px solid var(--p-surface-600, #404040);
|
|
523
|
+
border-radius: 10px;
|
|
524
|
+
padding: 1px 8px;
|
|
525
|
+
font-size: 10px;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.i18n-chip-hit {
|
|
529
|
+
border-color: #10b981;
|
|
530
|
+
color: #10b981;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.i18n-chip-miss {
|
|
534
|
+
border-color: #ef4444;
|
|
535
|
+
color: #ef4444;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.i18n-muted {
|
|
539
|
+
color: var(--p-text-muted-color, #888);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* Resolve tester */
|
|
543
|
+
.i18n-resolve-input {
|
|
544
|
+
display: flex;
|
|
545
|
+
gap: 6px;
|
|
546
|
+
margin-bottom: 10px;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.i18n-resolve-summary {
|
|
550
|
+
display: flex;
|
|
551
|
+
align-items: center;
|
|
552
|
+
gap: 8px;
|
|
553
|
+
padding: 8px 0;
|
|
554
|
+
border-bottom: 1px solid var(--p-surface-700, #2a2a2a);
|
|
555
|
+
margin-bottom: 8px;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.i18n-resolve-value {
|
|
559
|
+
font-family: var(--font-mono, monospace);
|
|
560
|
+
color: var(--p-primary-color, #ec4899);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.i18n-trace {
|
|
564
|
+
list-style: none;
|
|
565
|
+
padding: 0;
|
|
566
|
+
margin: 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.i18n-trace-step {
|
|
570
|
+
display: flex;
|
|
571
|
+
align-items: flex-start;
|
|
572
|
+
gap: 8px;
|
|
573
|
+
padding: 4px 0;
|
|
574
|
+
font-family: var(--font-mono, monospace);
|
|
575
|
+
font-size: 11px;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
.i18n-trace-kind {
|
|
579
|
+
width: 140px;
|
|
580
|
+
flex-shrink: 0;
|
|
581
|
+
color: var(--p-text-muted-color, #888);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.i18n-trace-detail {
|
|
585
|
+
word-break: break-all;
|
|
586
|
+
background: transparent;
|
|
587
|
+
color: var(--p-text-color, #ddd);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.i18n-step-miss .i18n-trace-kind { color: #ef4444; }
|
|
591
|
+
.i18n-step-snake-case-fallback .i18n-trace-kind { color: #f59e0b; }
|
|
592
|
+
.i18n-step-alias-pattern .i18n-trace-kind,
|
|
593
|
+
.i18n-step-alias-value .i18n-trace-kind { color: #06b6d4; }
|
|
594
|
+
|
|
595
|
+
/* Missing keys */
|
|
596
|
+
.i18n-missing-toolbar {
|
|
597
|
+
display: flex;
|
|
598
|
+
gap: 6px;
|
|
599
|
+
margin-bottom: 10px;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.i18n-missing-group {
|
|
603
|
+
margin-bottom: 12px;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.i18n-missing-group-header {
|
|
607
|
+
display: flex;
|
|
608
|
+
align-items: center;
|
|
609
|
+
gap: 6px;
|
|
610
|
+
margin-bottom: 4px;
|
|
611
|
+
font-weight: 600;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.i18n-missing-entry {
|
|
615
|
+
display: flex;
|
|
616
|
+
align-items: center;
|
|
617
|
+
gap: 8px;
|
|
618
|
+
padding: 3px 0 3px 20px;
|
|
619
|
+
font-family: var(--font-mono, monospace);
|
|
620
|
+
font-size: 11px;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.i18n-missing-key {
|
|
624
|
+
flex: 1;
|
|
625
|
+
background: transparent;
|
|
626
|
+
color: var(--p-text-color, #ddd);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.i18n-missing-time {
|
|
630
|
+
font-size: 10px;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* Coverage matrix */
|
|
634
|
+
.i18n-coverage-table {
|
|
635
|
+
border-collapse: collapse;
|
|
636
|
+
width: 100%;
|
|
637
|
+
margin-bottom: 12px;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.i18n-coverage-table th,
|
|
641
|
+
.i18n-coverage-table td {
|
|
642
|
+
border: 1px solid var(--p-surface-700, #2a2a2a);
|
|
643
|
+
padding: 6px 10px;
|
|
644
|
+
text-align: center;
|
|
645
|
+
font-size: 11px;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.i18n-coverage-table th {
|
|
649
|
+
background: var(--p-surface-800, #1f1f1f);
|
|
650
|
+
color: var(--p-text-muted-color, #888);
|
|
651
|
+
font-weight: 500;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
.i18n-coverage-entity {
|
|
655
|
+
text-align: left !important;
|
|
656
|
+
font-family: var(--font-mono, monospace);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.i18n-coverage-cell {
|
|
660
|
+
cursor: pointer;
|
|
661
|
+
transition: filter 0.1s;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.i18n-coverage-cell:hover {
|
|
665
|
+
filter: brightness(1.2);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.i18n-cov-green {
|
|
669
|
+
background: rgba(16, 185, 129, 0.15);
|
|
670
|
+
color: #10b981;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.i18n-cov-yellow {
|
|
674
|
+
background: rgba(245, 158, 11, 0.15);
|
|
675
|
+
color: #f59e0b;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.i18n-cov-red {
|
|
679
|
+
background: rgba(239, 68, 68, 0.15);
|
|
680
|
+
color: #ef4444;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.i18n-coverage-drill {
|
|
684
|
+
margin-top: 12px;
|
|
685
|
+
padding: 10px;
|
|
686
|
+
background: var(--p-surface-800, #1f1f1f);
|
|
687
|
+
border-radius: 4px;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.i18n-coverage-drill-header {
|
|
691
|
+
display: flex;
|
|
692
|
+
align-items: center;
|
|
693
|
+
gap: 8px;
|
|
694
|
+
margin-bottom: 8px;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.i18n-coverage-drill-header > .debug-toolbar-btn {
|
|
698
|
+
margin-left: auto;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.i18n-coverage-drill-list {
|
|
702
|
+
list-style: none;
|
|
703
|
+
padding: 0;
|
|
704
|
+
margin: 0;
|
|
705
|
+
font-family: var(--font-mono, monospace);
|
|
706
|
+
font-size: 11px;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
.i18n-coverage-drill-list li {
|
|
710
|
+
display: flex;
|
|
711
|
+
align-items: center;
|
|
712
|
+
gap: 8px;
|
|
713
|
+
padding: 3px 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.i18n-coverage-drill-list .i18n-cov-hit i { color: #10b981; }
|
|
717
|
+
.i18n-coverage-drill-list .i18n-cov-miss i { color: #ef4444; }
|
|
718
|
+
|
|
719
|
+
.i18n-coverage-result {
|
|
720
|
+
color: var(--p-text-muted-color, #888);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/* Bundle inspector */
|
|
724
|
+
.i18n-bundle-toolbar {
|
|
725
|
+
display: flex;
|
|
726
|
+
gap: 6px;
|
|
727
|
+
margin-bottom: 10px;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.i18n-bundle-tree {
|
|
731
|
+
background: var(--p-surface-900, #121212);
|
|
732
|
+
border: 1px solid var(--p-surface-700, #2a2a2a);
|
|
733
|
+
border-radius: 4px;
|
|
734
|
+
padding: 8px;
|
|
735
|
+
max-height: 600px;
|
|
736
|
+
overflow: auto;
|
|
737
|
+
}
|
|
738
|
+
</style>
|