From d68fc0021bf08800d38426ec5e9fad8939208ca3 Mon Sep 17 00:00:00 2001 From: Stanyslas Bres Date: Thu, 5 Feb 2026 22:46:43 +0100 Subject: [PATCH 1/2] feat(i18n): detect missing and dynamic translation keys --- .github/workflows/ci.yml | 22 +++++ app/components/ColumnPicker.vue | 26 +++--- app/components/Org/MembersPanel.vue | 24 +++-- app/components/Package/DownloadAnalytics.vue | 13 ++- app/components/Package/Replacement.vue | 71 +++++++-------- app/components/Package/VulnerabilityTree.vue | 15 +++- app/composables/useFacetSelection.ts | 64 +++++++++++++- package.json | 2 + pnpm-lock.yaml | 82 +++++++++++++++++ scripts/find-invalid-translations.ts | 92 ++++++++++++++++++++ 10 files changed, 352 insertions(+), 59 deletions(-) create mode 100644 scripts/find-invalid-translations.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8320ecf6c..faf2ea374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,3 +210,25 @@ jobs: - name: ๐Ÿงน Check for unused production code run: pnpm knip --production + + i18n: + name: ๐ŸŒ i18n validation + runs-on: ubuntu-24.04-arm + + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: lts/* + + - uses: pnpm/action-setup@1e1c8eafbd745f64b1ef30a7d7ed7965034c486c # 1e1c8eafbd745f64b1ef30a7d7ed7965034c486c + name: ๐ŸŸง Install pnpm + with: + cache: true + + - name: ๐Ÿ“ฆ Install dependencies (root only, no scripts) + run: pnpm install --filter . --ignore-scripts + + - name: ๐ŸŒ Check for missing or dynamic i18n keys + run: pnpm i18n:report diff --git a/app/components/ColumnPicker.vue b/app/components/ColumnPicker.vue index 6b8c8ea14..b9ae5b195 100644 --- a/app/components/ColumnPicker.vue +++ b/app/components/ColumnPicker.vue @@ -38,23 +38,23 @@ const toggleableColumns = computed(() => props.columns.filter(col => col.id !== // Map column IDs to i18n keys const columnLabelKey: Record = { - name: 'filters.columns.name', - version: 'filters.columns.version', - description: 'filters.columns.description', - downloads: 'filters.columns.downloads', - updated: 'filters.columns.published', - maintainers: 'filters.columns.maintainers', - keywords: 'filters.columns.keywords', - qualityScore: 'filters.columns.quality_score', - popularityScore: 'filters.columns.popularity_score', - maintenanceScore: 'filters.columns.maintenance_score', - combinedScore: 'filters.columns.combined_score', - security: 'filters.columns.security', + name: $t('filters.columns.name'), + version: $t('filters.columns.version'), + description: $t('filters.columns.description'), + downloads: $t('filters.columns.downloads'), + updated: $t('filters.columns.published'), + maintainers: $t('filters.columns.maintainers'), + keywords: $t('filters.columns.keywords'), + qualityScore: $t('filters.columns.quality_score'), + popularityScore: $t('filters.columns.popularity_score'), + maintenanceScore: $t('filters.columns.maintenance_score'), + combinedScore: $t('filters.columns.combined_score'), + security: $t('filters.columns.security'), } function getColumnLabel(id: string): string { const key = columnLabelKey[id] - return key ? $t(key) : id + return key ?? id } function handleReset() { diff --git a/app/components/Org/MembersPanel.vue b/app/components/Org/MembersPanel.vue index ebc694680..74ea429af 100644 --- a/app/components/Org/MembersPanel.vue +++ b/app/components/Org/MembersPanel.vue @@ -2,6 +2,9 @@ import type { NewOperation } from '~/composables/useConnector' import { buildScopeTeam } from '~/utils/npm/common' +type MemberRole = 'developer' | 'admin' | 'owner' +type MemberRoleFilter = MemberRole | 'all' + const props = defineProps<{ orgName: string }>() @@ -21,7 +24,7 @@ const { } = useConnector() // Members data: { username: role } -const members = shallowRef>({}) +const members = shallowRef>({}) const isLoading = shallowRef(false) const error = shallowRef(null) @@ -31,7 +34,7 @@ const isLoadingTeams = shallowRef(false) // Search/filter const searchQuery = shallowRef('') -const filterRole = shallowRef<'all' | 'developer' | 'admin' | 'owner'>('all') +const filterRole = shallowRef('all') const filterTeam = shallowRef(null) const sortBy = shallowRef<'name' | 'role'>('name') const sortOrder = shallowRef<'asc' | 'desc'>('asc') @@ -39,7 +42,7 @@ const sortOrder = shallowRef<'asc' | 'desc'>('asc') // Add member form const showAddMember = shallowRef(false) const newUsername = shallowRef('') -const newRole = shallowRef<'developer' | 'admin' | 'owner'>('developer') +const newRole = shallowRef('developer') const newTeam = shallowRef('') // Empty string means "developers" (default) const isAddingMember = shallowRef(false) @@ -259,6 +262,17 @@ function getRoleBadgeClass(role: string): string { } } +const roleLabels = { + owner: $t('org.members.role.owner'), + admin: $t('org.members.role.admin'), + developer: $t('org.members.role.developer'), + all: $t('org.members.role.all'), +} + +function getRoleLabel(role: MemberRoleFilter): string { + return roleLabels[role] +} + // Click on team badge to switch to teams tab and highlight function handleTeamClick(teamName: string) { emit('select-team', teamName) @@ -341,7 +355,7 @@ watch(lastExecutionTime, () => { :aria-pressed="filterRole === role" @click="filterRole = role" > - {{ $t(`org.members.role.${role}`) }} + {{ getRoleLabel(role) }} ({{ roleCounts[role] }}) @@ -439,7 +453,7 @@ watch(lastExecutionTime, () => { class="px-1.5 py-0.5 font-mono text-xs border rounded" :class="getRoleBadgeClass(member.role)" > - {{ member.role }} + {{ getRoleLabel(member.role) }}
diff --git a/app/components/Package/DownloadAnalytics.vue b/app/components/Package/DownloadAnalytics.vue index 07bfaa222..031e42303 100644 --- a/app/components/Package/DownloadAnalytics.vue +++ b/app/components/Package/DownloadAnalytics.vue @@ -785,6 +785,17 @@ function buildExportFilename(extension: string): string { return `${sanitise(label ?? '')}-${g}_${range}.${extension}` } +const granularityLabels = { + daily: $t('package.downloads.granularity_daily'), + weekly: $t('package.downloads.granularity_weekly'), + monthly: $t('package.downloads.granularity_monthly'), + yearly: $t('package.downloads.granularity_yearly'), +} + +function getGranularityLabel(granularity: ChartTimeGranularity) { + return granularityLabels[granularity] +} + // VueUiXy chart component configuration const chartConfig = computed(() => { return { @@ -835,7 +846,7 @@ const chartConfig = computed(() => { fontSize: isMobile.value ? 24 : 16, axis: { yLabel: $t('package.downloads.y_axis_label', { - granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`), + granularity: getGranularityLabel(selectedGranularity.value), }), xLabel: isMultiPackageMode.value ? '' : xAxisLabel.value, // for multiple series, names are displayed in the chart's legend yLabelOffsetX: 12, diff --git a/app/components/Package/Replacement.vue b/app/components/Package/Replacement.vue index 1e7065c5f..8f50f3a2a 100644 --- a/app/components/Package/Replacement.vue +++ b/app/components/Package/Replacement.vue @@ -5,38 +5,6 @@ const props = defineProps<{ replacement: ModuleReplacement }>() -const message = computed< - [string, { replacement?: string; nodeVersion?: string; community?: string }] ->(() => { - switch (props.replacement.type) { - case 'native': - return [ - 'package.replacement.native', - { - replacement: props.replacement.replacement, - nodeVersion: props.replacement.nodeVersion, - }, - ] - case 'simple': - return [ - 'package.replacement.simple', - { - replacement: props.replacement.replacement, - community: $t('package.replacement.community'), - }, - ] - case 'documented': - return [ - 'package.replacement.documented', - { - community: $t('package.replacement.community'), - }, - ] - case 'none': - return ['package.replacement.none', {}] - } -}) - const mdnUrl = computed(() => { if (props.replacement.type !== 'native' || !props.replacement.mdnPath) return null return `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/${props.replacement.mdnPath}` @@ -57,13 +25,43 @@ const docPath = computed(() => { {{ $t('package.replacement.title') }}

- + + + + + + + + ) { + return severityLabels[severity] +} + const summaryText = computed(() => { if (!vulnTree.value) return '' const { totalCounts } = vulnTree.value return SEVERITY_LEVELS.filter(s => totalCounts[s] > 0) - .map(s => `${totalCounts[s]} ${$t(`package.vulnerabilities.severity.${s}`)}`) + .map(s => `${totalCounts[s]} ${getPackageSeverityLabel(s)}`) .join(', ') }) @@ -130,7 +141,7 @@ function getDepthStyle(depth: string | undefined) { class="px-1.5 py-0.5 text-[10px] font-mono rounded border" :class="SEVERITY_COLORS[s]" > - {{ pkg.counts[s] }} {{ $t(`package.vulnerabilities.severity.${s}`) }} + {{ pkg.counts[s] }} {{ getPackageSeverityLabel(s) }}

diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index dba90440a..3763f17ea 100644 --- a/app/composables/useFacetSelection.ts +++ b/app/composables/useFacetSelection.ts @@ -23,13 +23,64 @@ export interface FacetInfoWithLabels extends Omit { export function useFacetSelection(queryParam = 'facets') { const { t } = useI18n() + const facetLabels = { + downloads: { + label: t(`compare.facets.items.downloads.label`), + description: t(`compare.facets.items.downloads.label`), + }, + packageSize: { + label: t(`compare.facets.items.packageSize.label`), + description: t(`compare.facets.items.packageSize.description`), + }, + installSize: { + label: t(`compare.facets.items.installSize.label`), + description: t(`compare.facets.items.installSize.description`), + }, + moduleFormat: { + label: t(`compare.facets.items.moduleFormat.label`), + description: t(`compare.facets.items.moduleFormat.description`), + }, + types: { + label: t(`compare.facets.items.types.label`), + description: t(`compare.facets.items.types.description`), + }, + engines: { + label: t(`compare.facets.items.engines.label`), + description: t(`compare.facets.items.engines.description`), + }, + vulnerabilities: { + label: t(`compare.facets.items.vulnerabilities.label`), + description: t(`compare.facets.items.vulnerabilities.description`), + }, + lastUpdated: { + label: t(`compare.facets.items.lastUpdated.label`), + description: t(`compare.facets.items.lastUpdated.description`), + }, + license: { + label: t(`compare.facets.items.license.label`), + description: t(`compare.facets.items.license.description`), + }, + dependencies: { + label: t(`compare.facets.items.dependencies.label`), + description: t(`compare.facets.items.dependencies.description`), + }, + totalDependencies: { + label: t(`compare.facets.items.totalDependencies.label`), + description: t(`compare.facets.items.totalDependencies.description`), + }, + deprecated: { + label: t(`compare.facets.items.deprecated.label`), + description: t(`compare.facets.items.deprecated.description`), + }, + } + // Helper to build facet info with i18n labels function buildFacetInfo(facet: ComparisonFacet): FacetInfoWithLabels { return { id: facet, ...FACET_INFO[facet], - label: t(`compare.facets.items.${facet}.label`), - description: t(`compare.facets.items.${facet}.description`), + label: facetLabels[facet].label, + description: facetLabels[facet].description, } } @@ -130,9 +181,16 @@ export function useFacetSelection(queryParam = 'facets') { // Check if only one facet is selected (minimum) const isNoneSelected = computed(() => selectedFacetIds.value.length === 1) + const facetCategories = { + performance: t(`compare.facets.categories.performance`), + health: t(`compare.facets.categories.health`), + compatibility: t(`compare.facets.categories.compatibility`), + security: t(`compare.facets.categories.security`), + } + // Get translated category name function getCategoryLabel(category: FacetInfo['category']): string { - return t(`compare.facets.categories.${category}`) + return facetCategories[category] } // All facets with their info and i18n labels, grouped by category diff --git a/package.json b/package.json index a2afadec3..36ff0b7fe 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:docs": "pnpm run --filter npmx-docs dev --port=3001", "i18n:check": "node scripts/compare-translations.ts", "i18n:check:fix": "node scripts/compare-translations.ts --fix", + "i18n:report": "node scripts/find-invalid-translations.ts", "knip": "knip", "knip:fix": "knip --fix", "lint": "oxlint && oxfmt --check", @@ -123,6 +124,7 @@ "typescript": "5.9.3", "vitest": "npm:@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab", "vitest-environment-nuxt": "1.0.1", + "vue-i18n-extract": "2.0.7", "vue-tsc": "3.2.4" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c464f3f31..1ac93f056 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: vitest-environment-nuxt: specifier: 1.0.1 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) + vue-i18n-extract: + specifier: 2.0.7 + version: 2.0.7 vue-tsc: specifier: 3.2.4 version: 3.2.4(typescript@5.9.3) @@ -5028,6 +5031,10 @@ packages: commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + common-tags@1.8.2: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} @@ -5359,6 +5366,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-object@2.1.5: + resolution: {integrity: sha512-xHF8EP4XH/Ba9fvAF2LDd5O3IITVolerVV6xvkxoM8zlGEiCUrggpAnHyOoKJKCrhvPcGATFAUwIujj7bRG5UA==} + hasBin: true + dot-prop@10.1.0: resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} engines: {node: '>=20'} @@ -5867,6 +5878,9 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5977,6 +5991,15 @@ packages: resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} engines: {node: 20 || >=22} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + 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 + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + 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 + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -6228,6 +6251,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + 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. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -6440,6 +6467,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-valid-glob@1.0.0: + resolution: {integrity: sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==} + engines: {node: '>=0.10.0'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -7527,6 +7558,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -9316,6 +9351,10 @@ packages: vue-flow-layout@0.2.0: resolution: {integrity: sha512-zKgsWWkXq0xrus7H4Mc+uFs1ESrmdTXlO0YNbR6wMdPaFvosL3fMB8N7uTV308UhGy9UvTrGhIY7mVz9eN+L0Q==} + vue-i18n-extract@2.0.7: + resolution: {integrity: sha512-i1NW5R58S720iQ1BEk+6ILo3hT6UA8mtYNNolSH4rt9345qvXdvA6GHy2+jHozdDAKHwlu9VvS/+vIMKs1UYQw==} + hasBin: true + vue-i18n@11.2.8: resolution: {integrity: sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==} engines: {node: '>= 16'} @@ -14972,6 +15011,8 @@ snapshots: commander@2.20.3: {} + commander@6.2.1: {} + common-tags@1.8.2: {} commondir@1.0.1: {} @@ -15374,6 +15415,11 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-object@2.1.5: + dependencies: + commander: 6.2.1 + glob: 7.2.3 + dot-prop@10.1.0: dependencies: type-fest: 5.4.2 @@ -16081,6 +16127,8 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -16205,6 +16253,23 @@ snapshots: minipass: 7.1.2 path-scurry: 2.0.1 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -16531,6 +16596,11 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} @@ -16762,6 +16832,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-valid-glob@1.0.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -18633,6 +18705,8 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -20741,6 +20815,14 @@ snapshots: vue-flow-layout@0.2.0: {} + vue-i18n-extract@2.0.7: + dependencies: + cac: 6.7.14 + dot-object: 2.1.5 + glob: 8.1.0 + is-valid-glob: 1.0.0 + js-yaml: 4.1.1 + vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)): dependencies: '@intlify/core-base': 11.2.8 diff --git a/scripts/find-invalid-translations.ts b/scripts/find-invalid-translations.ts new file mode 100644 index 000000000..7d97fe298 --- /dev/null +++ b/scripts/find-invalid-translations.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-console */ +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createI18NReport, type I18NItem } from 'vue-i18n-extract' + +const LOCALES_DIRECTORY = fileURLToPath(new URL('../i18n/locales', import.meta.url)) +const REFERENCE_FILE_NAME = 'en.json' +const VUE_FILES_GLOB = './app/**/*.?(vue|ts|js)' + +const colors = { + red: (text: string) => `\x1b[31m${text}\x1b[0m`, + green: (text: string) => `\x1b[32m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, + dim: (text: string) => `\x1b[2m${text}\x1b[0m`, + bold: (text: string) => `\x1b[1m${text}\x1b[0m`, +} + +function printSection( + title: string, + items: I18NItem[], + status: 'error' | 'warning' | 'success', +): void { + const icon = status === 'error' ? 'โŒ' : status === 'warning' ? 'โš ๏ธ' : 'โœ…' + const colorFn = + status === 'error' ? colors.red : status === 'warning' ? colors.yellow : colors.green + + console.log(`\n${icon} ${colors.bold(title)}: ${colorFn(String(items.length))}`) + + if (items.length === 0) return + + const groupedByFile = items.reduce>((acc, item) => { + const file = item.file ?? 'unknown' + acc[file] ??= [] + acc[file].push(item.path) + return acc + }, {}) + + for (const [file, keys] of Object.entries(groupedByFile)) { + console.log(` ${colors.dim(file)}`) + for (const key of keys) { + console.log(` ${colors.cyan(key)}`) + } + } +} + +async function run(): Promise { + console.log(colors.bold('\n๐Ÿ” Analyzing i18n translations...\n')) + + const { missingKeys, unusedKeys, maybeDynamicKeys } = await createI18NReport({ + vueFiles: VUE_FILES_GLOB, + languageFiles: join(LOCALES_DIRECTORY, REFERENCE_FILE_NAME), + }) + + const hasMissingKeys = missingKeys.length > 0 + const hasUnusedKeys = unusedKeys.length > 0 + const hasDynamicKeys = maybeDynamicKeys.length > 0 + + // Display missing keys (critical - causes build failure) + printSection('Missing keys', missingKeys, hasMissingKeys ? 'error' : 'success') + + // Display dynamic keys (critical - causes build failure) + printSection( + 'Dynamic keys (cannot be statically analyzed)', + maybeDynamicKeys, + hasDynamicKeys ? 'error' : 'success', + ) + + // Display unused keys (warning only - does not cause build failure) + printSection('Unused keys', unusedKeys, hasUnusedKeys ? 'warning' : 'success') + + // Summary + console.log('\n' + colors.dim('โ”€'.repeat(50))) + + const shouldFail = hasMissingKeys || hasDynamicKeys + + if (shouldFail) { + console.log(colors.red('\nโŒ Build failed: missing or dynamic keys detected')) + console.log(colors.dim(' Fix missing keys by adding them to the locale file')) + console.log(colors.dim(' Fix dynamic keys by using static translation keys\n')) + process.exit(1) + } + + if (hasUnusedKeys) { + console.log(colors.yellow('\nโš ๏ธ Build passed with warnings: unused keys detected')) + console.log(colors.dim(' Consider removing unused keys from locale files\n')) + } else { + console.log(colors.green('\nโœ… All translations are valid!\n')) + } +} + +run() From 5c712e496b02feab1ff2cf01e1050438000171d6 Mon Sep 17 00:00:00 2001 From: Stanyslas Bres Date: Thu, 5 Feb 2026 23:07:01 +0100 Subject: [PATCH 2/2] chore(i18n): review --- app/components/ColumnPicker.vue | 8 ++++---- app/components/Org/MembersPanel.vue | 12 ++++++------ app/components/Package/DownloadAnalytics.vue | 6 +++--- app/components/Package/VulnerabilityTree.vue | 6 +++--- app/composables/useFacetSelection.ts | 10 +++++----- scripts/find-invalid-translations.ts | 5 ++++- 6 files changed, 25 insertions(+), 22 deletions(-) diff --git a/app/components/ColumnPicker.vue b/app/components/ColumnPicker.vue index b9ae5b195..70aadf943 100644 --- a/app/components/ColumnPicker.vue +++ b/app/components/ColumnPicker.vue @@ -37,7 +37,7 @@ useEventListener('keydown', event => { const toggleableColumns = computed(() => props.columns.filter(col => col.id !== 'name')) // Map column IDs to i18n keys -const columnLabelKey: Record = { +const columnLabelKey = computed(() => ({ name: $t('filters.columns.name'), version: $t('filters.columns.version'), description: $t('filters.columns.description'), @@ -50,10 +50,10 @@ const columnLabelKey: Record = { maintenanceScore: $t('filters.columns.maintenance_score'), combinedScore: $t('filters.columns.combined_score'), security: $t('filters.columns.security'), -} +})) -function getColumnLabel(id: string): string { - const key = columnLabelKey[id] +function getColumnLabel(id: ColumnId): string { + const key = columnLabelKey.value[id] return key ?? id } diff --git a/app/components/Org/MembersPanel.vue b/app/components/Org/MembersPanel.vue index 74ea429af..5ad15717b 100644 --- a/app/components/Org/MembersPanel.vue +++ b/app/components/Org/MembersPanel.vue @@ -262,15 +262,15 @@ function getRoleBadgeClass(role: string): string { } } -const roleLabels = { +const roleLabels = computed(() => ({ owner: $t('org.members.role.owner'), admin: $t('org.members.role.admin'), developer: $t('org.members.role.developer'), all: $t('org.members.role.all'), -} +})) function getRoleLabel(role: MemberRoleFilter): string { - return roleLabels[role] + return roleLabels.value[role] } // Click on team badge to switch to teams tab and highlight @@ -473,9 +473,9 @@ watch(lastExecutionTime, () => { ) " > - - - + + +