Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 39 additions & 19 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <country-code>
```

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)

Expand Down Expand Up @@ -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
<!-- Template literal -->
<p>{{ $t(`package.tabs.${tab}`) }}</p>

<!-- Variable -->
<p>{{ $t(myKey) }}</p>
```

**Good:**

```typescript
const { t } = useI18n()

const tabLabels = computed(() => ({
readme: t('package.tabs.readme'),
versions: t('package.tabs.versions'),
}))
```

```vue
<p>{{ tabLabels[tab] }}</p>
```

### Using i18n-ally (recommended)

Expand Down
4 changes: 2 additions & 2 deletions app/components/ColumnPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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
}

Expand Down
109 changes: 63 additions & 46 deletions app/components/Filter/Panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const emit = defineEmits<{
'toggleKeyword': [keyword: string]
}>()

const { t } = useI18n()

const isExpanded = shallowRef(false)
const showAllKeywords = shallowRef(false)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) }}
</button>
</div>
</div>
Expand Down Expand Up @@ -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) }}
</TagRadioButton>
</div>
</fieldset>
Expand All @@ -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) }}
</TagRadioButton>
</div>
</fieldset>
Expand All @@ -296,7 +313,7 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
:value="security"
name="security"
>
{{ $t(getSecurityLabelKey(security)) }}
{{ getSecurityLabelKey(security) }}
</TagRadioButton>
</div>
</fieldset>
Expand Down
32 changes: 17 additions & 15 deletions app/components/Package/ListToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const props = defineProps<{
searchContext?: boolean
}>()

const { t } = useI18n()

const sortOption = defineModel<SortOption>('sortOption', { required: true })
const viewMode = defineModel<ViewMode>('viewMode', { required: true })
const paginationMode = defineModel<PaginationMode>('paginationMode', { required: true })
Expand Down Expand Up @@ -85,22 +87,22 @@ function handleToggleDirection() {
}

// Map sort key to i18n key
const sortKeyLabelKeys: Record<SortKey, string> = {
'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<Record<SortKey, string>>(() => ({
'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]
}
</script>

Expand Down Expand Up @@ -169,7 +171,7 @@ function getSortKeyLabelKey(key: SortKey): string {
:value="keyConfig.key"
:disabled="keyConfig.disabled"
>
{{ $t(getSortKeyLabelKey(keyConfig.key)) }}
{{ getSortKeyLabelKey(keyConfig.key) }}
</option>
</select>
<div
Expand Down
Loading
Loading