Skip to content

Commit 88be800

Browse files
sybersdanielroe
andauthored
feat(i18n): detect missing and dynamic translation keys (#1046)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent e96b8e6 commit 88be800

File tree

10 files changed

+362
-66
lines changed

10 files changed

+362
-66
lines changed

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,25 @@ jobs:
210210

211211
- name: 🧹 Check for unused production code
212212
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

app/components/ColumnPicker.vue

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,24 +41,24 @@ onKeyDown(
4141
const toggleableColumns = computed(() => props.columns.filter(col => col.id !== 'name'))
4242
4343
// 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+
}))
5858
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
6262
}
6363
6464
function handleReset() {

app/components/Org/MembersPanel.vue

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import type { NewOperation } from '~/composables/useConnector'
33
import { buildScopeTeam } from '~/utils/npm/common'
44
5+
type MemberRole = 'developer' | 'admin' | 'owner'
6+
type MemberRoleFilter = MemberRole | 'all'
7+
58
const props = defineProps<{
69
orgName: string
710
}>()
@@ -21,7 +24,7 @@ const {
2124
} = useConnector()
2225
2326
// Members data: { username: role }
24-
const members = shallowRef<Record<string, 'developer' | 'admin' | 'owner'>>({})
27+
const members = shallowRef<Record<string, MemberRole>>({})
2528
const isLoading = shallowRef(false)
2629
const error = shallowRef<string | null>(null)
2730
@@ -31,15 +34,15 @@ const isLoadingTeams = shallowRef(false)
3134
3235
// Search/filter
3336
const searchQuery = shallowRef('')
34-
const filterRole = shallowRef<'all' | 'developer' | 'admin' | 'owner'>('all')
37+
const filterRole = shallowRef<MemberRoleFilter>('all')
3538
const filterTeam = shallowRef<string | null>(null)
3639
const sortBy = shallowRef<'name' | 'role'>('name')
3740
const sortOrder = shallowRef<'asc' | 'desc'>('asc')
3841
3942
// Add member form
4043
const showAddMember = shallowRef(false)
4144
const newUsername = shallowRef('')
42-
const newRole = shallowRef<'developer' | 'admin' | 'owner'>('developer')
45+
const newRole = shallowRef<MemberRole>('developer')
4346
const newTeam = shallowRef<string>('') // Empty string means "developers" (default)
4447
const isAddingMember = shallowRef(false)
4548
@@ -259,6 +262,17 @@ function getRoleBadgeClass(role: string): string {
259262
}
260263
}
261264
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+
262276
// Click on team badge to switch to teams tab and highlight
263277
function handleTeamClick(teamName: string) {
264278
emit('select-team', teamName)
@@ -341,7 +355,7 @@ watch(lastExecutionTime, () => {
341355
:aria-pressed="filterRole === role"
342356
@click="filterRole = role"
343357
>
344-
{{ $t(`org.members.role.${role}`) }}
358+
{{ getRoleLabel(role) }}
345359
<span v-if="role !== 'all'" class="text-fg-subtle">({{ roleCounts[role] }})</span>
346360
</button>
347361
</div>
@@ -439,7 +453,7 @@ watch(lastExecutionTime, () => {
439453
class="px-1.5 py-0.5 font-mono text-xs border rounded"
440454
:class="getRoleBadgeClass(member.role)"
441455
>
442-
{{ member.role }}
456+
{{ getRoleLabel(member.role) }}
443457
</span>
444458
</div>
445459
<div class="flex items-center gap-1">
@@ -459,9 +473,9 @@ watch(lastExecutionTime, () => {
459473
)
460474
"
461475
>
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>
465479
</select>
466480
<!-- Remove button -->
467481
<button

app/components/Package/DownloadAnalytics.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,17 @@ function buildExportFilename(extension: string): string {
785785
return `${sanitise(label ?? '')}-${g}_${range}.${extension}`
786786
}
787787
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+
788799
// VueUiXy chart component configuration
789800
const chartConfig = computed(() => {
790801
return {
@@ -835,7 +846,7 @@ const chartConfig = computed(() => {
835846
fontSize: isMobile.value ? 24 : 16,
836847
axis: {
837848
yLabel: $t('package.downloads.y_axis_label', {
838-
granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`),
849+
granularity: getGranularityLabel(selectedGranularity.value),
839850
}),
840851
xLabel: isMultiPackageMode.value ? '' : xAxisLabel.value, // for multiple series, names are displayed in the chart's legend
841852
yLabelOffsetX: 12,

app/components/Package/Replacement.vue

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,6 @@ const props = defineProps<{
55
replacement: ModuleReplacement
66
}>()
77
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-
408
const mdnUrl = computed(() => {
419
if (props.replacement.type !== 'native' || !props.replacement.mdnPath) return null
4210
return `https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/${props.replacement.mdnPath}`
@@ -57,13 +25,43 @@ const docPath = computed(() => {
5725
{{ $t('package.replacement.title') }}
5826
</h2>
5927
<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+
>
6133
<template #replacement>
62-
{{ message[1].replacement ?? '' }}
34+
{{ replacement.replacement }}
6335
</template>
6436
<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 }}
6658
</template>
59+
</i18n-t>
60+
<i18n-t
61+
v-else-if="replacement.type === 'documented'"
62+
keypath="package.replacement.documented"
63+
scope="global"
64+
>
6765
<template #community>
6866
<a
6967
href="https://e18e.dev/docs/replacements/"
@@ -76,6 +74,9 @@ const docPath = computed(() => {
7674
</a>
7775
</template>
7876
</i18n-t>
77+
<template v-else>
78+
{{ $t('package.replacement.none') }}
79+
</template>
7980
<a
8081
v-if="mdnUrl"
8182
:href="mdnUrl"

app/components/Package/VulnerabilityTree.vue

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,22 @@ const hasVulnerabilities = computed(
2323
// Banner - amber for better light mode contrast
2424
const bannerColor = 'border-amber-600/40 bg-amber-500/10 text-amber-700 dark:text-amber-400'
2525
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+
2637
const summaryText = computed(() => {
2738
if (!vulnTree.value) return ''
2839
const { totalCounts } = vulnTree.value
2940
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)}`)
3142
.join(', ')
3243
})
3344
@@ -130,7 +141,7 @@ function getDepthStyle(depth: string | undefined) {
130141
class="px-1.5 py-0.5 text-[10px] font-mono rounded border"
131142
:class="SEVERITY_COLORS[s]"
132143
>
133-
{{ pkg.counts[s] }} {{ $t(`package.vulnerabilities.severity.${s}`) }}
144+
{{ pkg.counts[s] }} {{ getPackageSeverityLabel(s) }}
134145
</span>
135146
</div>
136147
</div>

app/composables/useFacetSelection.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,64 @@ export interface FacetInfoWithLabels extends Omit<FacetInfo, 'id'> {
2323
export function useFacetSelection(queryParam = 'facets') {
2424
const { t } = useI18n()
2525

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+
2677
// Helper to build facet info with i18n labels
2778
function buildFacetInfo(facet: ComparisonFacet): FacetInfoWithLabels {
2879
return {
2980
id: facet,
3081
...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,
3384
}
3485
}
3586

@@ -130,9 +181,16 @@ export function useFacetSelection(queryParam = 'facets') {
130181
// Check if only one facet is selected (minimum)
131182
const isNoneSelected = computed(() => selectedFacetIds.value.length === 1)
132183

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+
133191
// Get translated category name
134192
function getCategoryLabel(category: FacetInfo['category']): string {
135-
return t(`compare.facets.categories.${category}`)
193+
return facetCategories[category]
136194
}
137195

138196
// All facets with their info and i18n labels, grouped by category

0 commit comments

Comments
 (0)