diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f60611433..d46df804b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,7 @@ This focus helps guide our project decisions as a community and what we choose t - [RTL Support](#rtl-support) - [Localization (i18n)](#localization-i18n) - [Approach](#approach) + - [i18n commands](#i18n-commands) - [Adding a new locale](#adding-a-new-locale) - [Update translation](#update-translation) - [Adding translations](#adding-translations) @@ -378,6 +379,17 @@ npmx.dev uses [@nuxtjs/i18n](https://i18n.nuxtjs.org/) for internationalization. - We use the `no_prefix` strategy (no `/en-US/` or `/fr-FR/` in URLs) - Locale preference is stored in cookies and respected on subsequent visits +### i18n commands + +The following scripts help manage translation files. `en.json` is the reference locale. + +| Command | Description | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm i18n:check [locale]` | Compares `en.json` with other locale files. Shows missing and extra keys. Optionally filter output by locale (e.g. `pnpm i18n:check ja-JP`). | +| `pnpm i18n:check:fix [locale]` | Same as check, but adds missing keys to other locales with English placeholders. | +| `pnpm i18n:report` | Audits translation keys against code usage in `.vue` and `.ts` files. Reports missing keys (used in code but not in locale), unused keys (in locale but not in code), and dynamic keys. | +| `pnpm i18n:report:fix` | Removes unused keys from `en.json` and all other locale files. | + ### Adding a new locale We are using localization using country variants (ISO-6391) via [multiple translation files](https://i18n.nuxtjs.org/docs/guide/lazy-load-translations#multiple-files-lazy-loading) to avoid repeating every key per country. @@ -421,25 +433,7 @@ Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essential We track the current progress of translations with [Lunaria](https://lunaria.dev/) on this site: https://i18n.npmx.dev/ If you see any outdated translations in your language, feel free to update the keys to match the English version. -In order to make sure you have everything up-to-date, you can run: - -```bash -pnpm i18n:check -``` - -For example to check if all Japanese translation keys are up-to-date, run: - -```bash -pnpm i18n:check ja-JP -``` - -To automatically add missing keys with English placeholders, use `--fix`: - -```bash -pnpm i18n:check:fix fr-FR -``` - -This will add missing keys with `"EN TEXT TO REPLACE: {english text}"` as placeholder values, making it easier to see what needs translation. +Use `pnpm i18n:check` and `pnpm i18n:check:fix` to verify and fix your locale (see [i18n commands](#i18n-commands) above for details). #### Country variants (advanced) @@ -527,6 +521,32 @@ See how `es`, `es-ES`, and `es-419` are configured in [config/i18n.ts](./config/ - Use `common.*` for shared strings (loading, retry, close, etc.) - Use component-specific prefixes: `package.card.*`, `settings.*`, `nav.*` - Do not use dashes (`-`) in translation keys; always use underscore (`_`): e.g., `privacy_policy` instead of `privacy-policy` +- **Always use static string literals as translation keys.** Our i18n scripts (`pnpm i18n:report`) rely on static analysis to detect unused and missing keys. Dynamic keys cannot be analyzed and will be flagged as errors. + + **Bad:** + + ```vue + +

{{ $t(`package.tabs.${tab}`) }}

+ + +

{{ $t(myKey) }}

+ ``` + + **Good:** + + ```typescript + const { t } = useI18n() + + const tabLabels = computed(() => ({ + readme: t('package.tabs.readme'), + versions: t('package.tabs.versions'), + })) + ``` + + ```vue +

{{ tabLabels[tab] }}

+ ``` ### Using i18n-ally (recommended) diff --git a/app/components/ColumnPicker.vue b/app/components/ColumnPicker.vue index e254b77b3..a7d63dc02 100644 --- a/app/components/ColumnPicker.vue +++ b/app/components/ColumnPicker.vue @@ -41,7 +41,7 @@ onKeyDown( const toggleableColumns = computed(() => props.columns.filter(col => col.id !== 'name')) // Map column IDs to i18n keys -const columnLabelKey = computed(() => ({ +const columnLabels = computed(() => ({ name: $t('filters.columns.name'), version: $t('filters.columns.version'), description: $t('filters.columns.description'), @@ -57,7 +57,7 @@ const columnLabelKey = computed(() => ({ })) function getColumnLabel(id: ColumnId): string { - const key = columnLabelKey.value[id] + const key = columnLabels.value[id] return key ?? id } diff --git a/app/components/Filter/Panel.vue b/app/components/Filter/Panel.vue index e81449bd6..6acc03a27 100644 --- a/app/components/Filter/Panel.vue +++ b/app/components/Filter/Panel.vue @@ -27,6 +27,8 @@ const emit = defineEmits<{ 'toggleKeyword': [keyword: string] }>() +const { t } = useI18n() + const isExpanded = shallowRef(false) const showAllKeywords = shallowRef(false) @@ -55,62 +57,77 @@ const hasMoreKeywords = computed(() => { }) // i18n mappings for filter options -const scopeLabelKeys = { - name: 'filters.scope_name', - description: 'filters.scope_description', - keywords: 'filters.scope_keywords', - all: 'filters.scope_all', -} as const - -const scopeDescriptionKeys = { - name: 'filters.scope_name_description', - description: 'filters.scope_description_description', - keywords: 'filters.scope_keywords_description', - all: 'filters.scope_all_description', -} as const - -const downloadRangeLabelKeys = { - 'any': 'filters.download_range.any', - 'lt100': 'filters.download_range.lt100', - '100-1k': 'filters.download_range.100_1k', - '1k-10k': 'filters.download_range.1k_10k', - '10k-100k': 'filters.download_range.10k_100k', - 'gt100k': 'filters.download_range.gt100k', -} as const - -const updatedWithinLabelKeys = { - any: 'filters.updated.any', - week: 'filters.updated.week', - month: 'filters.updated.month', - quarter: 'filters.updated.quarter', - year: 'filters.updated.year', -} as const - -const securityLabelKeys = { - all: 'filters.security_options.all', - secure: 'filters.security_options.secure', - warnings: 'filters.security_options.insecure', -} as const +const scopeLabelKeys = computed( + () => + ({ + name: t('filters.scope_name'), + description: t('filters.scope_description'), + keywords: t('filters.scope_keywords'), + all: t('filters.scope_all'), + }) as const, +) + +const scopeDescriptionKeys = computed( + () => + ({ + name: t('filters.scope_name_description'), + description: t('filters.scope_description_description'), + keywords: t('filters.scope_keywords_description'), + all: t('filters.scope_all_description'), + }) as const, +) + +const downloadRangeLabelKeys = computed( + () => + ({ + 'any': t('filters.download_range.any'), + 'lt100': t('filters.download_range.lt100'), + '100-1k': t('filters.download_range.100_1k'), + '1k-10k': t('filters.download_range.1k_10k'), + '10k-100k': t('filters.download_range.10k_100k'), + 'gt100k': t('filters.download_range.gt100k'), + }) as const, +) + +const updatedWithinLabelKeys = computed( + () => + ({ + any: t('filters.updated.any'), + week: t('filters.updated.week'), + month: t('filters.updated.month'), + quarter: t('filters.updated.quarter'), + year: t('filters.updated.year'), + }) as const, +) + +const securityLabelKeys = computed( + () => + ({ + all: t('filters.security_options.all'), + secure: t('filters.security_options.secure'), + warnings: t('filters.security_options.insecure'), + }) as const, +) // Type-safe accessor functions function getScopeLabelKey(value: SearchScope): string { - return scopeLabelKeys[value] + return scopeLabelKeys.value[value] } function getScopeDescriptionKey(value: SearchScope): string { - return scopeDescriptionKeys[value] + return scopeDescriptionKeys.value[value] } function getDownloadRangeLabelKey(value: DownloadRange): string { - return downloadRangeLabelKeys[value] + return downloadRangeLabelKeys.value[value] } function getUpdatedWithinLabelKey(value: UpdatedWithin): string { - return updatedWithinLabelKeys[value] + return updatedWithinLabelKeys.value[value] } function getSecurityLabelKey(value: SecurityFilter): string { - return securityLabelKeys[value] + return securityLabelKeys.value[value] } function handleTextInput(event: Event) { @@ -215,10 +232,10 @@ const hasActiveFilters = computed(() => !!filterSummary.value) : 'text-fg-muted hover:text-fg' " :aria-pressed="filters.searchScope === scope" - :title="$t(getScopeDescriptionKey(scope))" + :title="getScopeDescriptionKey(scope)" @click="emit('update:searchScope', scope)" > - {{ $t(getScopeLabelKey(scope)) }} + {{ getScopeLabelKey(scope) }} @@ -251,7 +268,7 @@ const hasActiveFilters = computed(() => !!filterSummary.value) @update:modelValue="emit('update:downloadRange', $event as DownloadRange)" name="range" > - {{ $t(getDownloadRangeLabelKey(range.value)) }} + {{ getDownloadRangeLabelKey(range.value) }} @@ -274,7 +291,7 @@ const hasActiveFilters = computed(() => !!filterSummary.value) name="updatedWithin" @update:modelValue="emit('update:updatedWithin', $event as UpdatedWithin)" > - {{ $t(getUpdatedWithinLabelKey(option.value)) }} + {{ getUpdatedWithinLabelKey(option.value) }} @@ -296,7 +313,7 @@ const hasActiveFilters = computed(() => !!filterSummary.value) :value="security" name="security" > - {{ $t(getSecurityLabelKey(security)) }} + {{ getSecurityLabelKey(security) }} diff --git a/app/components/Package/ListToolbar.vue b/app/components/Package/ListToolbar.vue index 811e42996..44fd16e1b 100644 --- a/app/components/Package/ListToolbar.vue +++ b/app/components/Package/ListToolbar.vue @@ -32,6 +32,8 @@ const props = defineProps<{ searchContext?: boolean }>() +const { t } = useI18n() + const sortOption = defineModel('sortOption', { required: true }) const viewMode = defineModel('viewMode', { required: true }) const paginationMode = defineModel('paginationMode', { required: true }) @@ -85,22 +87,22 @@ function handleToggleDirection() { } // Map sort key to i18n key -const sortKeyLabelKeys: Record = { - 'relevance': 'filters.sort.relevance', - 'downloads-week': 'filters.sort.downloads_week', - 'downloads-day': 'filters.sort.downloads_day', - 'downloads-month': 'filters.sort.downloads_month', - 'downloads-year': 'filters.sort.downloads_year', - 'updated': 'filters.sort.published', - 'name': 'filters.sort.name', - 'quality': 'filters.sort.quality', - 'popularity': 'filters.sort.popularity', - 'maintenance': 'filters.sort.maintenance', - 'score': 'filters.sort.score', -} +const sortKeyLabelKeys = computed>(() => ({ + 'relevance': t('filters.sort.relevance'), + 'downloads-week': t('filters.sort.downloads_week'), + 'downloads-day': t('filters.sort.downloads_day'), + 'downloads-month': t('filters.sort.downloads_month'), + 'downloads-year': t('filters.sort.downloads_year'), + 'updated': t('filters.sort.published'), + 'name': t('filters.sort.name'), + 'quality': t('filters.sort.quality'), + 'popularity': t('filters.sort.popularity'), + 'maintenance': t('filters.sort.maintenance'), + 'score': t('filters.sort.score'), +})) function getSortKeyLabelKey(key: SortKey): string { - return sortKeyLabelKeys[key] + return sortKeyLabelKeys.value[key] } @@ -169,7 +171,7 @@ function getSortKeyLabelKey(key: SortKey): string { :value="keyConfig.key" :disabled="keyConfig.disabled" > - {{ $t(getSortKeyLabelKey(keyConfig.key)) }} + {{ getSortKeyLabelKey(keyConfig.key) }}
() +const { t } = useI18n() + const sortOption = defineModel('sortOption') const emit = defineEmits<{ @@ -87,23 +89,23 @@ function toggleSort(id: string) { } // Map column IDs to i18n keys -const columnLabelKeys: 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 columnLabels = 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 getColumnLabelKey(id: ColumnId): string { - return columnLabelKeys[id] +function getColumnLabel(id: ColumnId): string { + return columnLabels.value[id] } @@ -133,7 +135,7 @@ function getColumnLabelKey(id: ColumnId): string { @keydown.space.prevent="toggleSort('name')" > - {{ $t(getColumnLabelKey('name')) }} + {{ getColumnLabel('name') }}