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
2 changes: 1 addition & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode", "Vue.volar"]
"recommendations": ["oxc.oxc-vscode", "Vue.volar", "lokalise.i18n-ally"]
}
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"i18n-ally.localesPaths": ["./i18n/locales"],
"i18n-ally.keystyle": "nested"
}
83 changes: 83 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,89 @@ const props = defineProps<{

Ideally, extract utilities into separate files so they can be unit tested. 🙏

## Localization (i18n)

npmx.dev uses [@nuxtjs/i18n](https://i18n.nuxtjs.org/) for internationalization. We aim to make the UI accessible to users in their preferred language.

### Approach

- All user-facing strings should use translation keys via `$t()` in templates or `t()` in script
- Translation files live in `i18n/locales/` (e.g., `en.json`)
- We use the `no_prefix` strategy (no `/en/` or `/fr/` in URLs)
- Locale preference is stored in cookies and respected on subsequent visits

### Adding translations

1. Add your translation key to `i18n/locales/en.json` first (English is the source of truth)
2. Use the key in your component:

```vue
<template>
<p>{{ $t('my.translation.key') }}</p>
</template>
```

Or in script:

```typescript
const { t } = useI18n()
const message = t('my.translation.key')
```

3. For dynamic values, use interpolation:

```json
{ "greeting": "Hello, {name}!" }
```

```vue
<p>{{ $t('greeting', { name: userName }) }}</p>
```

### Translation key conventions

- Use dot notation for hierarchy: `section.subsection.key`
- Keep keys descriptive but concise
- Group related keys together
- Use `common.*` for shared strings (loading, retry, close, etc.)
- Use component-specific prefixes: `package.card.*`, `settings.*`, `nav.*`

### Using i18n-ally (recommended)

We recommend the [i18n-ally](https://marketplace.visualstudio.com/items?itemName=lokalise.i18n-ally) VSCode extension for a better development experience:

- Inline translation previews in your code
- Auto-completion for translation keys
- Missing translation detection
- Easy navigation to translation files

The extension is included in our workspace recommendations, so VSCode should prompt you to install it.

### Adding a new locale

1. Create a new JSON file in `i18n/locales/` (e.g., `fr.json`)
2. Add the locale to `nuxt.config.ts`:

```typescript
i18n: {
locales: [
{ code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
{ code: 'fr', language: 'fr-FR', name: 'Francais', file: 'fr.json' },
],
}
```

3. Translate all keys from `en.json`

### Formatting with locale

When formatting numbers or dates that should respect the user's locale, pass the locale:

```typescript
const { locale } = useI18n()
const formatted = formatNumber(12345, locale.value) // "12,345" in en-US
```

## Testing

### Unit tests
Expand Down
12 changes: 6 additions & 6 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,35 +85,35 @@ onMounted(() => {
>
<div class="container py-2 sm:py-6 flex flex-col gap-1 sm:gap-3 text-fg-subtle text-sm">
<div class="flex flex-row items-center justify-between gap-2 sm:gap-4">
<p class="font-mono m-0 hidden sm:block">a better browser for the npm registry</p>
<p class="font-mono m-0 hidden sm:block">{{ $t('tagline') }}</p>
<!-- On mobile, show disclaimer here instead of tagline -->
<p class="text-xs text-fg-muted m-0 sm:hidden">not affiliated with npm, Inc.</p>
<p class="text-xs text-fg-muted m-0 sm:hidden">{{ $t('non_affiliation_disclaimer') }}</p>
<div class="flex items-center gap-4 sm:gap-6">
<a
href="https://repo.npmx.dev"
rel="noopener noreferrer"
class="link-subtle font-mono text-xs min-h-11 min-w- flex items-center"
>
source
{{ $t('footer.source') }}
</a>
<a
href="https://social.npmx.dev"
rel="noopener noreferrer"
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
>
social
{{ $t('footer.social') }}
</a>
<a
href="https://chat.npmx.dev"
rel="noopener noreferrer"
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
>
chat
{{ $t('footer.chat') }}
</a>
</div>
</div>
<p class="text-xs text-fg-muted text-center sm:text-left m-0 hidden sm:block">
npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.
{{ $t('trademark_disclaimer') }}
</p>
</div>
</footer>
Expand Down
6 changes: 3 additions & 3 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const { isConnected, npmUser } = useConnector()
<NuxtLink
v-if="showLogo"
to="/"
aria-label="npmx home"
:aria-label="$t('header.home')"
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
>
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx
Expand All @@ -38,7 +38,7 @@ const { isConnected, npmUser } = useConnector()
class="link-subtle font-mono text-sm inline-flex items-center gap-2"
aria-keyshortcuts="/"
>
search
{{ $t('nav.search') }}
<kbd
class="hidden sm:inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded"
aria-hidden="true"
Expand Down Expand Up @@ -74,7 +74,7 @@ const { isConnected, npmUser } = useConnector()
target="_blank"
rel="noopener noreferrer"
class="link-subtle"
aria-label="GitHub repository"
:aria-label="$t('header.github')"
>
<span class="i-carbon-logo-github w-5 h-5" aria-hidden="true" />
</a>
Expand Down
66 changes: 35 additions & 31 deletions app/components/ClaimPackageModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const props = defineProps<{

const open = defineModel<boolean>('open', { default: false })

const { t } = useI18n()

const {
isConnected,
state,
Expand All @@ -32,7 +34,7 @@ async function checkAvailability() {
try {
checkResult.value = await checkPackageName(props.packageName)
} catch (err) {
publishError.value = err instanceof Error ? err.message : 'Failed to check name availability'
publishError.value = err instanceof Error ? err.message : t('claim.modal.failed_to_check')
} finally {
isChecking.value = false
}
Expand Down Expand Up @@ -82,7 +84,7 @@ async function handleClaim() {
connectorModalOpen.value = true
}
} catch (err) {
publishError.value = err instanceof Error ? err.message : 'Failed to claim package'
publishError.value = err instanceof Error ? err.message : t('claim.modal.failed_to_claim')
} finally {
isPublishing.value = false
}
Expand Down Expand Up @@ -141,7 +143,7 @@ const connectorModalOpen = shallowRef(false)
<button
type="button"
class="absolute inset-0 bg-black/60 cursor-default"
aria-label="Close modal"
:aria-label="$t('claim.modal.close_modal')"
@click="open = false"
/>

Expand All @@ -155,12 +157,12 @@ const connectorModalOpen = shallowRef(false)
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 id="claim-modal-title" class="font-mono text-lg font-medium">
Claim Package Name
{{ $t('claim.modal.title') }}
</h2>
<button
type="button"
class="text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
aria-label="Close"
:aria-label="$t('claim.modal.close')"
@click="open = false"
>
<span class="i-carbon-close block w-5 h-5" aria-hidden="true" />
Expand All @@ -169,7 +171,7 @@ const connectorModalOpen = shallowRef(false)

<!-- Loading state -->
<div v-if="isChecking" class="py-8 text-center">
<LoadingSpinner text="Checking availability…" />
<LoadingSpinner :text="t('claim.modal.checking')" />
</div>

<!-- Success state -->
Expand All @@ -179,16 +181,15 @@ const connectorModalOpen = shallowRef(false)
>
<span class="i-carbon-checkmark-filled text-green-500 w-6 h-6" aria-hidden="true" />
<div>
<p class="font-mono text-sm text-fg">Package claimed!</p>
<p class="font-mono text-sm text-fg">{{ $t('claim.modal.success') }}</p>
<p class="text-xs text-fg-muted">
{{ packageName }}@0.0.0 has been published to npm.
{{ $t('claim.modal.success_detail', { name: packageName }) }}
</p>
</div>
</div>

<p class="text-sm text-fg-muted">
You can now publish new versions to this package using
<code class="font-mono bg-bg-subtle px-1 rounded">npm publish</code>.
{{ $t('claim.modal.success_hint') }}
</p>

<div class="flex gap-3">
Expand All @@ -197,14 +198,14 @@ const connectorModalOpen = shallowRef(false)
class="flex-1 px-4 py-2 font-mono text-sm text-center text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="open = false"
>
View Package
{{ $t('claim.modal.view_package') }}
</NuxtLink>
<button
type="button"
class="flex-1 px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="open = false"
>
Close
{{ $t('claim.modal.close') }}
</button>
</div>
</div>
Expand All @@ -222,7 +223,7 @@ const connectorModalOpen = shallowRef(false)
class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md"
role="alert"
>
<p class="font-medium mb-1">Invalid package name:</p>
<p class="font-medium mb-1">{{ $t('claim.modal.invalid_name') }}</p>
<ul class="list-disc list-inside space-y-1">
<li v-for="err in checkResult.validationErrors" :key="err">{{ err }}</li>
</ul>
Expand All @@ -234,7 +235,7 @@ const connectorModalOpen = shallowRef(false)
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
role="alert"
>
<p class="font-medium mb-1">Warnings:</p>
<p class="font-medium mb-1">{{ $t('common.warnings') }}</p>
<ul class="list-disc list-inside space-y-1">
<li v-for="warn in checkResult.validationWarnings" :key="warn">{{ warn }}</li>
</ul>
Expand All @@ -250,15 +251,15 @@ const connectorModalOpen = shallowRef(false)
class="i-carbon-checkmark-filled text-green-500 w-5 h-5"
aria-hidden="true"
/>
<p class="font-mono text-sm text-fg">This name is available!</p>
<p class="font-mono text-sm text-fg">{{ $t('claim.modal.available') }}</p>
</div>

<div
v-else
class="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/20 rounded-lg"
>
<span class="i-carbon-close-filled text-red-500 w-5 h-5" aria-hidden="true" />
<p class="font-mono text-sm text-fg">This name is already taken.</p>
<p class="font-mono text-sm text-fg">{{ $t('claim.modal.taken') }}</p>
</div>
</div>

Expand All @@ -277,9 +278,9 @@ const connectorModalOpen = shallowRef(false)
class="text-sm font-medium mb-3"
>
<span v-if="hasDangerousSimilarPackages">
Similar packages exist - npm may reject this name:
{{ $t('claim.modal.similar_warning') }}
</span>
<span v-else> Related packages: </span>
<span v-else>{{ $t('claim.modal.related') }}</span>
</p>
<ul class="space-y-2">
<li
Expand Down Expand Up @@ -331,13 +332,14 @@ const connectorModalOpen = shallowRef(false)
v-if="!isScoped"
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
>
<p class="font-medium mb-1">Consider using a scoped package instead</p>
<p class="font-medium mb-1">{{ $t('claim.modal.scope_warning_title') }}</p>
<p class="text-xs text-yellow-400/80">
Unscoped package names are a shared resource. Only claim a name if you intend to
publish and maintain a package. For personal or organizational projects, use a
scoped name like
<code class="font-mono">@{{ npmUser || 'username' }}/{{ packageName }}</code
>.
{{
$t('claim.modal.scope_warning_text', {
username: npmUser || 'username',
name: packageName,
})
}}
</p>
</div>

Expand All @@ -346,29 +348,29 @@ const connectorModalOpen = shallowRef(false)
<div
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
>
<p>Connect to the local connector to claim this package name.</p>
<p>{{ $t('claim.modal.connect_required') }}</p>
</div>
<button
type="button"
class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="connectorModalOpen = true"
>
Connect to Connector
{{ $t('claim.modal.connect_button') }}
</button>
</div>

<!-- Claim button -->
<div v-else class="space-y-3">
<p class="text-sm text-fg-muted">
This will publish a minimal placeholder package.
{{ $t('claim.modal.publish_hint') }}
</p>

<!-- Expandable package.json preview -->
<details class="border border-border rounded-md overflow-hidden">
<summary
class="px-3 py-2 text-sm text-fg-muted bg-bg-subtle cursor-pointer hover:text-fg transition-colors select-none"
>
Preview package.json
{{ $t('claim.modal.preview_json') }}
</summary>
<pre class="p-3 text-xs font-mono text-fg-muted bg-[#0d0d0d] overflow-x-auto">{{
JSON.stringify(previewPackageJson, null, 2)
Expand All @@ -381,7 +383,9 @@ const connectorModalOpen = shallowRef(false)
class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-colors duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="handleClaim"
>
{{ isPublishing ? 'Publishing…' : 'Claim Package Name' }}
{{
isPublishing ? $t('claim.modal.publishing') : $t('claim.modal.claim_button')
}}
</button>
</div>
</div>
Expand All @@ -393,7 +397,7 @@ const connectorModalOpen = shallowRef(false)
class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="open = false"
>
Close
{{ $t('claim.modal.close') }}
</button>
</div>

Expand All @@ -410,7 +414,7 @@ const connectorModalOpen = shallowRef(false)
class="w-full px-4 py-2 font-mono text-sm text-fg-muted bg-bg-subtle border border-border rounded-md transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
@click="checkAvailability"
>
Retry
{{ $t('claim.modal.retry') }}
</button>
</div>
</div>
Expand Down
Loading