[READ-ONLY] a fast, modern browser for the npm registry

feat(i18n): detect missing and dynamic translation keys (#1046)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Stanyslas Bres
Daniel Roe
and committed by
GitHub
88be8000 e96b8e6e

+362 -66
+22
.github/workflows/ci.yml
··· 210 210 211 211 - name: 🧹 Check for unused production code 212 212 run: pnpm knip --production 213 + 214 + i18n: 215 + name: 🌐 i18n validation 216 + runs-on: ubuntu-24.04-arm 217 + 218 + steps: 219 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 220 + 221 + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 222 + with: 223 + node-version: lts/* 224 + 225 + - uses: pnpm/action-setup@1e1c8eafbd745f64b1ef30a7d7ed7965034c486c # 1e1c8eafbd745f64b1ef30a7d7ed7965034c486c 226 + name: 🟧 Install pnpm 227 + with: 228 + cache: true 229 + 230 + - name: 📦 Install dependencies (root only, no scripts) 231 + run: pnpm install --filter . --ignore-scripts 232 + 233 + - name: 🌐 Check for missing or dynamic i18n keys 234 + run: pnpm i18n:report
+17 -17
app/components/ColumnPicker.vue
··· 41 41 const toggleableColumns = computed(() => props.columns.filter(col => col.id !== 'name')) 42 42 43 43 // Map column IDs to i18n keys 44 - const columnLabelKey: Record<string, string> = { 45 - name: 'filters.columns.name', 46 - version: 'filters.columns.version', 47 - description: 'filters.columns.description', 48 - downloads: 'filters.columns.downloads', 49 - updated: 'filters.columns.published', 50 - maintainers: 'filters.columns.maintainers', 51 - keywords: 'filters.columns.keywords', 52 - qualityScore: 'filters.columns.quality_score', 53 - popularityScore: 'filters.columns.popularity_score', 54 - maintenanceScore: 'filters.columns.maintenance_score', 55 - combinedScore: 'filters.columns.combined_score', 56 - security: 'filters.columns.security', 57 - } 44 + const columnLabelKey = computed(() => ({ 45 + name: $t('filters.columns.name'), 46 + version: $t('filters.columns.version'), 47 + description: $t('filters.columns.description'), 48 + downloads: $t('filters.columns.downloads'), 49 + updated: $t('filters.columns.published'), 50 + maintainers: $t('filters.columns.maintainers'), 51 + keywords: $t('filters.columns.keywords'), 52 + qualityScore: $t('filters.columns.quality_score'), 53 + popularityScore: $t('filters.columns.popularity_score'), 54 + maintenanceScore: $t('filters.columns.maintenance_score'), 55 + combinedScore: $t('filters.columns.combined_score'), 56 + security: $t('filters.columns.security'), 57 + })) 58 58 59 - function getColumnLabel(id: string): string { 60 - const key = columnLabelKey[id] 61 - return key ? $t(key) : id 59 + function getColumnLabel(id: ColumnId): string { 60 + const key = columnLabelKey.value[id] 61 + return key ?? id 62 62 } 63 63 64 64 function handleReset() {
+22 -8
app/components/Org/MembersPanel.vue
··· 2 2 import type { NewOperation } from '~/composables/useConnector' 3 3 import { buildScopeTeam } from '~/utils/npm/common' 4 4 5 + type MemberRole = 'developer' | 'admin' | 'owner' 6 + type MemberRoleFilter = MemberRole | 'all' 7 + 5 8 const props = defineProps<{ 6 9 orgName: string 7 10 }>() ··· 21 24 } = useConnector() 22 25 23 26 // Members data: { username: role } 24 - const members = shallowRef<Record<string, 'developer' | 'admin' | 'owner'>>({}) 27 + const members = shallowRef<Record<string, MemberRole>>({}) 25 28 const isLoading = shallowRef(false) 26 29 const error = shallowRef<string | null>(null) 27 30 ··· 31 34 32 35 // Search/filter 33 36 const searchQuery = shallowRef('') 34 - const filterRole = shallowRef<'all' | 'developer' | 'admin' | 'owner'>('all') 37 + const filterRole = shallowRef<MemberRoleFilter>('all') 35 38 const filterTeam = shallowRef<string | null>(null) 36 39 const sortBy = shallowRef<'name' | 'role'>('name') 37 40 const sortOrder = shallowRef<'asc' | 'desc'>('asc') ··· 39 42 // Add member form 40 43 const showAddMember = shallowRef(false) 41 44 const newUsername = shallowRef('') 42 - const newRole = shallowRef<'developer' | 'admin' | 'owner'>('developer') 45 + const newRole = shallowRef<MemberRole>('developer') 43 46 const newTeam = shallowRef<string>('') // Empty string means "developers" (default) 44 47 const isAddingMember = shallowRef(false) 45 48 ··· 259 262 } 260 263 } 261 264 265 + const roleLabels = computed(() => ({ 266 + owner: $t('org.members.role.owner'), 267 + admin: $t('org.members.role.admin'), 268 + developer: $t('org.members.role.developer'), 269 + all: $t('org.members.role.all'), 270 + })) 271 + 272 + function getRoleLabel(role: MemberRoleFilter): string { 273 + return roleLabels.value[role] 274 + } 275 + 262 276 // Click on team badge to switch to teams tab and highlight 263 277 function handleTeamClick(teamName: string) { 264 278 emit('select-team', teamName) ··· 341 355 :aria-pressed="filterRole === role" 342 356 @click="filterRole = role" 343 357 > 344 - {{ $t(`org.members.role.${role}`) }} 358 + {{ getRoleLabel(role) }} 345 359 <span v-if="role !== 'all'" class="text-fg-subtle">({{ roleCounts[role] }})</span> 346 360 </button> 347 361 </div> ··· 439 453 class="px-1.5 py-0.5 font-mono text-xs border rounded" 440 454 :class="getRoleBadgeClass(member.role)" 441 455 > 442 - {{ member.role }} 456 + {{ getRoleLabel(member.role) }} 443 457 </span> 444 458 </div> 445 459 <div class="flex items-center gap-1"> ··· 459 473 ) 460 474 " 461 475 > 462 - <option value="developer">{{ $t('org.members.role.developer') }}</option> 463 - <option value="admin">{{ $t('org.members.role.admin') }}</option> 464 - <option value="owner">{{ $t('org.members.role.owner') }}</option> 476 + <option value="developer">{{ getRoleLabel('developer') }}</option> 477 + <option value="admin">{{ getRoleLabel('admin') }}</option> 478 + <option value="owner">{{ getRoleLabel('owner') }}</option> 465 479 </select> 466 480 <!-- Remove button --> 467 481 <button
+12 -1
app/components/Package/DownloadAnalytics.vue
··· 785 785 return `${sanitise(label ?? '')}-${g}_${range}.${extension}` 786 786 } 787 787 788 + const granularityLabels = computed(() => ({ 789 + daily: $t('package.downloads.granularity_daily'), 790 + weekly: $t('package.downloads.granularity_weekly'), 791 + monthly: $t('package.downloads.granularity_monthly'), 792 + yearly: $t('package.downloads.granularity_yearly'), 793 + })) 794 + 795 + function getGranularityLabel(granularity: ChartTimeGranularity) { 796 + return granularityLabels.value[granularity] 797 + } 798 + 788 799 // VueUiXy chart component configuration 789 800 const chartConfig = computed(() => { 790 801 return { ··· 835 846 fontSize: isMobile.value ? 24 : 16, 836 847 axis: { 837 848 yLabel: $t('package.downloads.y_axis_label', { 838 - granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`), 849 + granularity: getGranularityLabel(selectedGranularity.value), 839 850 }), 840 851 xLabel: isMultiPackageMode.value ? '' : xAxisLabel.value, // for multiple series, names are displayed in the chart's legend 841 852 yLabelOffsetX: 12,
+36 -35
app/components/Package/Replacement.vue
··· 5 5 replacement: ModuleReplacement 6 6 }>() 7 7 8 - const message = computed< 9 - [string, { replacement?: string; nodeVersion?: string; community?: string }] 10 - >(() => { 11 - switch (props.replacement.type) { 12 - case 'native': 13 - return [ 14 - 'package.replacement.native', 15 - { 16 - replacement: props.replacement.replacement, 17 - nodeVersion: props.replacement.nodeVersion, 18 - }, 19 - ] 20 - case 'simple': 21 - return [ 22 - 'package.replacement.simple', 23 - { 24 - replacement: props.replacement.replacement, 25 - community: $t('package.replacement.community'), 26 - }, 27 - ] 28 - case 'documented': 29 - return [ 30 - 'package.replacement.documented', 31 - { 32 - community: $t('package.replacement.community'), 33 - }, 34 - ] 35 - case 'none': 36 - return ['package.replacement.none', {}] 37 - } 38 - }) 39 - 40 8 const mdnUrl = computed(() => { 41 9 if (props.replacement.type !== 'native' || !props.replacement.mdnPath) return null 42 10 return `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/${props.replacement.mdnPath}` ··· 57 25 {{ $t('package.replacement.title') }} 58 26 </h2> 59 27 <p class="text-sm m-0"> 60 - <i18n-t :keypath="message[0]" scope="global"> 28 + <i18n-t 29 + v-if="replacement.type === 'native'" 30 + keypath="package.replacement.native" 31 + scope="global" 32 + > 61 33 <template #replacement> 62 - {{ message[1].replacement ?? '' }} 34 + {{ replacement.replacement }} 63 35 </template> 64 36 <template #nodeVersion> 65 - {{ message[1].nodeVersion ?? '' }} 37 + {{ replacement.nodeVersion }} 38 + </template> 39 + </i18n-t> 40 + <i18n-t 41 + v-else-if="replacement.type === 'simple'" 42 + keypath="package.replacement.simple" 43 + scope="global" 44 + > 45 + <template #community> 46 + <a 47 + href="https://e18e.dev/docs/replacements/" 48 + target="_blank" 49 + rel="noopener noreferrer" 50 + class="inline-flex items-center gap-1 ms-1 underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg transition-colors" 51 + > 52 + {{ $t('package.replacement.community') }} 53 + <span class="i-carbon-launch w-3 h-3" aria-hidden="true" /> 54 + </a> 55 + </template> 56 + <template #replacement> 57 + {{ replacement.replacement }} 66 58 </template> 59 + </i18n-t> 60 + <i18n-t 61 + v-else-if="replacement.type === 'documented'" 62 + keypath="package.replacement.documented" 63 + scope="global" 64 + > 67 65 <template #community> 68 66 <a 69 67 href="https://e18e.dev/docs/replacements/" ··· 76 74 </a> 77 75 </template> 78 76 </i18n-t> 77 + <template v-else> 78 + {{ $t('package.replacement.none') }} 79 + </template> 79 80 <a 80 81 v-if="mdnUrl" 81 82 :href="mdnUrl"
+13 -2
app/components/Package/VulnerabilityTree.vue
··· 23 23 // Banner - amber for better light mode contrast 24 24 const bannerColor = 'border-amber-600/40 bg-amber-500/10 text-amber-700 dark:text-amber-400' 25 25 26 + const severityLabels = computed(() => ({ 27 + critical: $t('package.vulnerabilities.severity.critical'), 28 + high: $t('package.vulnerabilities.severity.high'), 29 + moderate: $t('package.vulnerabilities.severity.moderate'), 30 + low: $t('package.vulnerabilities.severity.low'), 31 + })) 32 + 33 + function getPackageSeverityLabel(severity: Exclude<OsvSeverityLevel, 'unknown'>) { 34 + return severityLabels.value[severity] 35 + } 36 + 26 37 const summaryText = computed(() => { 27 38 if (!vulnTree.value) return '' 28 39 const { totalCounts } = vulnTree.value 29 40 return SEVERITY_LEVELS.filter(s => totalCounts[s] > 0) 30 - .map(s => `${totalCounts[s]} ${$t(`package.vulnerabilities.severity.${s}`)}`) 41 + .map(s => `${totalCounts[s]} ${getPackageSeverityLabel(s)}`) 31 42 .join(', ') 32 43 }) 33 44 ··· 130 141 class="px-1.5 py-0.5 text-[10px] font-mono rounded border" 131 142 :class="SEVERITY_COLORS[s]" 132 143 > 133 - {{ pkg.counts[s] }} {{ $t(`package.vulnerabilities.severity.${s}`) }} 144 + {{ pkg.counts[s] }} {{ getPackageSeverityLabel(s) }} 134 145 </span> 135 146 </div> 136 147 </div>
+61 -3
app/composables/useFacetSelection.ts
··· 23 23 export function useFacetSelection(queryParam = 'facets') { 24 24 const { t } = useI18n() 25 25 26 + const facetLabels = computed(() => ({ 27 + downloads: { 28 + label: t(`compare.facets.items.downloads.label`), 29 + description: t(`compare.facets.items.downloads.description`), 30 + }, 31 + packageSize: { 32 + label: t(`compare.facets.items.packageSize.label`), 33 + description: t(`compare.facets.items.packageSize.description`), 34 + }, 35 + installSize: { 36 + label: t(`compare.facets.items.installSize.label`), 37 + description: t(`compare.facets.items.installSize.description`), 38 + }, 39 + moduleFormat: { 40 + label: t(`compare.facets.items.moduleFormat.label`), 41 + description: t(`compare.facets.items.moduleFormat.description`), 42 + }, 43 + types: { 44 + label: t(`compare.facets.items.types.label`), 45 + description: t(`compare.facets.items.types.description`), 46 + }, 47 + engines: { 48 + label: t(`compare.facets.items.engines.label`), 49 + description: t(`compare.facets.items.engines.description`), 50 + }, 51 + vulnerabilities: { 52 + label: t(`compare.facets.items.vulnerabilities.label`), 53 + description: t(`compare.facets.items.vulnerabilities.description`), 54 + }, 55 + lastUpdated: { 56 + label: t(`compare.facets.items.lastUpdated.label`), 57 + description: t(`compare.facets.items.lastUpdated.description`), 58 + }, 59 + license: { 60 + label: t(`compare.facets.items.license.label`), 61 + description: t(`compare.facets.items.license.description`), 62 + }, 63 + dependencies: { 64 + label: t(`compare.facets.items.dependencies.label`), 65 + description: t(`compare.facets.items.dependencies.description`), 66 + }, 67 + totalDependencies: { 68 + label: t(`compare.facets.items.totalDependencies.label`), 69 + description: t(`compare.facets.items.totalDependencies.description`), 70 + }, 71 + deprecated: { 72 + label: t(`compare.facets.items.deprecated.label`), 73 + description: t(`compare.facets.items.deprecated.description`), 74 + }, 75 + })) 76 + 26 77 // Helper to build facet info with i18n labels 27 78 function buildFacetInfo(facet: ComparisonFacet): FacetInfoWithLabels { 28 79 return { 29 80 id: facet, 30 81 ...FACET_INFO[facet], 31 - label: t(`compare.facets.items.${facet}.label`), 32 - description: t(`compare.facets.items.${facet}.description`), 82 + label: facetLabels.value[facet].label, 83 + description: facetLabels.value[facet].description, 33 84 } 34 85 } 35 86 ··· 130 181 // Check if only one facet is selected (minimum) 131 182 const isNoneSelected = computed(() => selectedFacetIds.value.length === 1) 132 183 184 + const facetCategories = { 185 + performance: t(`compare.facets.categories.performance`), 186 + health: t(`compare.facets.categories.health`), 187 + compatibility: t(`compare.facets.categories.compatibility`), 188 + security: t(`compare.facets.categories.security`), 189 + } 190 + 133 191 // Get translated category name 134 192 function getCategoryLabel(category: FacetInfo['category']): string { 135 - return t(`compare.facets.categories.${category}`) 193 + return facetCategories[category] 136 194 } 137 195 138 196 // All facets with their info and i18n labels, grouped by category
+2
package.json
··· 17 17 "dev:docs": "pnpm run --filter npmx-docs dev --port=3001", 18 18 "i18n:check": "node scripts/compare-translations.ts", 19 19 "i18n:check:fix": "node scripts/compare-translations.ts --fix", 20 + "i18n:report": "node scripts/find-invalid-translations.ts", 20 21 "knip": "knip", 21 22 "knip:fix": "knip --fix", 22 23 "lint": "oxlint && oxfmt --check", ··· 124 125 "typescript": "5.9.3", 125 126 "vitest": "npm:@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab", 126 127 "vitest-environment-nuxt": "1.0.1", 128 + "vue-i18n-extract": "2.0.7", 127 129 "vue-tsc": "3.2.4" 128 130 }, 129 131 "engines": {
+82
pnpm-lock.yaml
··· 270 270 vitest-environment-nuxt: 271 271 specifier: 1.0.1 272 272 version: 1.0.1(@playwright/test@1.58.1)(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.2)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(@vue/test-utils@2.4.6)(happy-dom@20.4.0)(magicast@0.5.1)(playwright-core@1.58.1)(typescript@5.9.3) 273 + vue-i18n-extract: 274 + specifier: 2.0.7 275 + version: 2.0.7 273 276 vue-tsc: 274 277 specifier: 3.2.4 275 278 version: 3.2.4(typescript@5.9.3) ··· 5031 5034 commander@2.20.3: 5032 5035 resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} 5033 5036 5037 + commander@6.2.1: 5038 + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} 5039 + engines: {node: '>= 6'} 5040 + 5034 5041 common-tags@1.8.2: 5035 5042 resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} 5036 5043 engines: {node: '>=4.0.0'} ··· 5361 5368 5362 5369 domutils@3.2.2: 5363 5370 resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} 5371 + 5372 + dot-object@2.1.5: 5373 + resolution: {integrity: sha512-xHF8EP4XH/Ba9fvAF2LDd5O3IITVolerVV6xvkxoM8zlGEiCUrggpAnHyOoKJKCrhvPcGATFAUwIujj7bRG5UA==} 5374 + hasBin: true 5364 5375 5365 5376 dot-prop@10.1.0: 5366 5377 resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} ··· 5870 5881 resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} 5871 5882 engines: {node: '>=10'} 5872 5883 5884 + fs.realpath@1.0.0: 5885 + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} 5886 + 5873 5887 fsevents@2.3.2: 5874 5888 resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} 5875 5889 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} ··· 5980 5994 resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} 5981 5995 engines: {node: 20 || >=22} 5982 5996 5997 + glob@7.2.3: 5998 + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} 5999 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 6000 + 6001 + glob@8.1.0: 6002 + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} 6003 + engines: {node: '>=12'} 6004 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 6005 + 5983 6006 global-directory@4.0.1: 5984 6007 resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} 5985 6008 engines: {node: '>=18'} ··· 6231 6254 resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} 6232 6255 engines: {node: '>=0.8.19'} 6233 6256 6257 + inflight@1.0.6: 6258 + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} 6259 + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. 6260 + 6234 6261 inherits@2.0.4: 6235 6262 resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 6236 6263 ··· 6442 6469 is-unicode-supported@2.1.0: 6443 6470 resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} 6444 6471 engines: {node: '>=18'} 6472 + 6473 + is-valid-glob@1.0.0: 6474 + resolution: {integrity: sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==} 6475 + engines: {node: '>=0.10.0'} 6445 6476 6446 6477 is-weakmap@2.0.2: 6447 6478 resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} ··· 7529 7560 path-exists@4.0.0: 7530 7561 resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 7531 7562 engines: {node: '>=8'} 7563 + 7564 + path-is-absolute@1.0.1: 7565 + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} 7566 + engines: {node: '>=0.10.0'} 7532 7567 7533 7568 path-key@3.1.1: 7534 7569 resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} ··· 9318 9353 9319 9354 vue-flow-layout@0.2.0: 9320 9355 resolution: {integrity: sha512-zKgsWWkXq0xrus7H4Mc+uFs1ESrmdTXlO0YNbR6wMdPaFvosL3fMB8N7uTV308UhGy9UvTrGhIY7mVz9eN+L0Q==} 9356 + 9357 + vue-i18n-extract@2.0.7: 9358 + resolution: {integrity: sha512-i1NW5R58S720iQ1BEk+6ILo3hT6UA8mtYNNolSH4rt9345qvXdvA6GHy2+jHozdDAKHwlu9VvS/+vIMKs1UYQw==} 9359 + hasBin: true 9321 9360 9322 9361 vue-i18n@11.2.8: 9323 9362 resolution: {integrity: sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==} ··· 14974 15013 commander@14.0.2: {} 14975 15014 14976 15015 commander@2.20.3: {} 15016 + 15017 + commander@6.2.1: {} 14977 15018 14978 15019 common-tags@1.8.2: {} 14979 15020 ··· 15377 15418 domelementtype: 2.3.0 15378 15419 domhandler: 5.0.3 15379 15420 15421 + dot-object@2.1.5: 15422 + dependencies: 15423 + commander: 6.2.1 15424 + glob: 7.2.3 15425 + 15380 15426 dot-prop@10.1.0: 15381 15427 dependencies: 15382 15428 type-fest: 5.4.2 ··· 16084 16130 jsonfile: 6.2.0 16085 16131 universalify: 2.0.1 16086 16132 16133 + fs.realpath@1.0.0: {} 16134 + 16087 16135 fsevents@2.3.2: 16088 16136 optional: true 16089 16137 ··· 16208 16256 minipass: 7.1.2 16209 16257 path-scurry: 2.0.1 16210 16258 16259 + glob@7.2.3: 16260 + dependencies: 16261 + fs.realpath: 1.0.0 16262 + inflight: 1.0.6 16263 + inherits: 2.0.4 16264 + minimatch: 3.1.2 16265 + once: 1.4.0 16266 + path-is-absolute: 1.0.1 16267 + 16268 + glob@8.1.0: 16269 + dependencies: 16270 + fs.realpath: 1.0.0 16271 + inflight: 1.0.6 16272 + inherits: 2.0.4 16273 + minimatch: 5.1.6 16274 + once: 1.4.0 16275 + 16211 16276 global-directory@4.0.1: 16212 16277 dependencies: 16213 16278 ini: 4.1.1 ··· 16534 16599 16535 16600 imurmurhash@0.1.4: {} 16536 16601 16602 + inflight@1.0.6: 16603 + dependencies: 16604 + once: 1.4.0 16605 + wrappy: 1.0.2 16606 + 16537 16607 inherits@2.0.4: {} 16538 16608 16539 16609 ini@1.3.8: {} ··· 16764 16834 which-typed-array: 1.1.20 16765 16835 16766 16836 is-unicode-supported@2.1.0: {} 16837 + 16838 + is-valid-glob@1.0.0: {} 16767 16839 16768 16840 is-weakmap@2.0.2: {} 16769 16841 ··· 18636 18708 18637 18709 path-exists@4.0.0: {} 18638 18710 18711 + path-is-absolute@1.0.1: {} 18712 + 18639 18713 path-key@3.1.1: {} 18640 18714 18641 18715 path-key@4.0.0: {} ··· 20743 20817 vue-devtools-stub@0.1.0: {} 20744 20818 20745 20819 vue-flow-layout@0.2.0: {} 20820 + 20821 + vue-i18n-extract@2.0.7: 20822 + dependencies: 20823 + cac: 6.7.14 20824 + dot-object: 2.1.5 20825 + glob: 8.1.0 20826 + is-valid-glob: 1.0.0 20827 + js-yaml: 4.1.1 20746 20828 20747 20829 vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)): 20748 20830 dependencies:
+95
scripts/find-invalid-translations.ts
··· 1 + /* eslint-disable no-console */ 2 + import { join } from 'node:path' 3 + import { fileURLToPath } from 'node:url' 4 + import { createI18NReport, type I18NItem } from 'vue-i18n-extract' 5 + 6 + const LOCALES_DIRECTORY = fileURLToPath(new URL('../i18n/locales', import.meta.url)) 7 + const REFERENCE_FILE_NAME = 'en.json' 8 + const VUE_FILES_GLOB = './app/**/*.?(vue|ts|js)' 9 + 10 + const colors = { 11 + red: (text: string) => `\x1b[31m${text}\x1b[0m`, 12 + green: (text: string) => `\x1b[32m${text}\x1b[0m`, 13 + yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, 14 + cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, 15 + dim: (text: string) => `\x1b[2m${text}\x1b[0m`, 16 + bold: (text: string) => `\x1b[1m${text}\x1b[0m`, 17 + } 18 + 19 + function printSection( 20 + title: string, 21 + items: I18NItem[], 22 + status: 'error' | 'warning' | 'success', 23 + ): void { 24 + const icon = status === 'error' ? '❌' : status === 'warning' ? '⚠️' : '✅' 25 + const colorFn = 26 + status === 'error' ? colors.red : status === 'warning' ? colors.yellow : colors.green 27 + 28 + console.log(`\n${icon} ${colors.bold(title)}: ${colorFn(String(items.length))}`) 29 + 30 + if (items.length === 0) return 31 + 32 + const groupedByFile = items.reduce<Record<string, string[]>>((acc, item) => { 33 + const file = item.file ?? 'unknown' 34 + acc[file] ??= [] 35 + acc[file].push(item.path) 36 + return acc 37 + }, {}) 38 + 39 + for (const [file, keys] of Object.entries(groupedByFile)) { 40 + console.log(` ${colors.dim(file)}`) 41 + for (const key of keys) { 42 + console.log(` ${colors.cyan(key)}`) 43 + } 44 + } 45 + } 46 + 47 + async function run(): Promise<void> { 48 + console.log(colors.bold('\n🔍 Analyzing i18n translations...\n')) 49 + 50 + const { missingKeys, unusedKeys, maybeDynamicKeys } = await createI18NReport({ 51 + vueFiles: VUE_FILES_GLOB, 52 + languageFiles: join(LOCALES_DIRECTORY, REFERENCE_FILE_NAME), 53 + }) 54 + 55 + const hasMissingKeys = missingKeys.length > 0 56 + const hasUnusedKeys = unusedKeys.length > 0 57 + const hasDynamicKeys = maybeDynamicKeys.length > 0 58 + 59 + // Display missing keys (critical - causes build failure) 60 + printSection('Missing keys', missingKeys, hasMissingKeys ? 'error' : 'success') 61 + 62 + // Display dynamic keys (critical - causes build failure) 63 + printSection( 64 + 'Dynamic keys (cannot be statically analyzed)', 65 + maybeDynamicKeys, 66 + hasDynamicKeys ? 'error' : 'success', 67 + ) 68 + 69 + // Display unused keys (warning only - does not cause build failure) 70 + printSection('Unused keys', unusedKeys, hasUnusedKeys ? 'warning' : 'success') 71 + 72 + // Summary 73 + console.log('\n' + colors.dim('─'.repeat(50))) 74 + 75 + const shouldFail = hasMissingKeys || hasDynamicKeys 76 + 77 + if (shouldFail) { 78 + console.log(colors.red('\n❌ Build failed: missing or dynamic keys detected')) 79 + console.log(colors.dim(' Fix missing keys by adding them to the locale file')) 80 + console.log(colors.dim(' Fix dynamic keys by using static translation keys\n')) 81 + process.exit(1) 82 + } 83 + 84 + if (hasUnusedKeys) { 85 + console.log(colors.yellow('\n⚠️ Build passed with warnings: unused keys detected')) 86 + console.log(colors.dim(' Consider removing unused keys from locale files\n')) 87 + } else { 88 + console.log(colors.green('\n✅ All translations are valid!\n')) 89 + } 90 + } 91 + 92 + run().catch((error: unknown) => { 93 + console.error(colors.red('\n❌ Unexpected error:'), error) 94 + process.exit(1) 95 + })