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 9d551eb2d..9eff548c3 100644 --- a/app/components/ColumnPicker.vue +++ b/app/components/ColumnPicker.vue @@ -41,24 +41,24 @@ onKeyDown( const toggleableColumns = computed(() => props.columns.filter(col => col.id !== 'name')) // 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', -} +const columnLabelKey = computed(() => ({ + 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 +function getColumnLabel(id: ColumnId): string { + const key = columnLabelKey.value[id] + return key ?? id } function handleReset() { diff --git a/app/components/Org/MembersPanel.vue b/app/components/Org/MembersPanel.vue index ebc694680..5ad15717b 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 = 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.value[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) }}
@@ -459,9 +473,9 @@ watch(lastExecutionTime, () => { ) " > - - - + + +
diff --git a/app/composables/useFacetSelection.ts b/app/composables/useFacetSelection.ts index dba90440a..31d503119 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 = computed(() => ({ + downloads: { + label: t(`compare.facets.items.downloads.label`), + description: t(`compare.facets.items.downloads.description`), + }, + 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.value[facet].label, + description: facetLabels.value[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 30caadcf5..4426bcd5b 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", @@ -124,6 +125,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 79d94fb3a..e74f0ad4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,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) @@ -5031,6 +5034,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'} @@ -5362,6 +5369,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'} @@ -5870,6 +5881,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} @@ -5980,6 +5994,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'} @@ -6231,6 +6254,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==} @@ -6443,6 +6470,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'} @@ -7530,6 +7561,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'} @@ -9319,6 +9354,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'} @@ -14975,6 +15014,8 @@ snapshots: commander@2.20.3: {} + commander@6.2.1: {} + common-tags@1.8.2: {} commondir@1.0.1: {} @@ -15377,6 +15418,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 @@ -16084,6 +16130,8 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -16208,6 +16256,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 @@ -16534,6 +16599,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: {} @@ -16765,6 +16835,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-valid-glob@1.0.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -18636,6 +18708,8 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -20744,6 +20818,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..991d8ea25 --- /dev/null +++ b/scripts/find-invalid-translations.ts @@ -0,0 +1,95 @@ +/* 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().catch((error: unknown) => { + console.error(colors.red('\nโŒ Unexpected error:'), error) + process.exit(1) +})