Skip to content

Commit 04f94fe

Browse files
feat: add i18n support (#166)
1 parent 205556c commit 04f94fe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2051
-538
lines changed

.vscode/extensions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"recommendations": ["oxc.oxc-vscode", "Vue.volar"]
2+
"recommendations": ["oxc.oxc-vscode", "Vue.volar", "lokalise.i18n-ally"]
33
}

.vscode/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"i18n-ally.localesPaths": ["./i18n/locales"],
3+
"i18n-ally.keystyle": "nested"
4+
}

CONTRIBUTING.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,89 @@ const props = defineProps<{
208208

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

211+
## Localization (i18n)
212+
213+
npmx.dev uses [@nuxtjs/i18n](https://i18n.nuxtjs.org/) for internationalization. We aim to make the UI accessible to users in their preferred language.
214+
215+
### Approach
216+
217+
- All user-facing strings should use translation keys via `$t()` in templates or `t()` in script
218+
- Translation files live in `i18n/locales/` (e.g., `en.json`)
219+
- We use the `no_prefix` strategy (no `/en/` or `/fr/` in URLs)
220+
- Locale preference is stored in cookies and respected on subsequent visits
221+
222+
### Adding translations
223+
224+
1. Add your translation key to `i18n/locales/en.json` first (English is the source of truth)
225+
2. Use the key in your component:
226+
227+
```vue
228+
<template>
229+
<p>{{ $t('my.translation.key') }}</p>
230+
</template>
231+
```
232+
233+
Or in script:
234+
235+
```typescript
236+
const { t } = useI18n()
237+
const message = t('my.translation.key')
238+
```
239+
240+
3. For dynamic values, use interpolation:
241+
242+
```json
243+
{ "greeting": "Hello, {name}!" }
244+
```
245+
246+
```vue
247+
<p>{{ $t('greeting', { name: userName }) }}</p>
248+
```
249+
250+
### Translation key conventions
251+
252+
- Use dot notation for hierarchy: `section.subsection.key`
253+
- Keep keys descriptive but concise
254+
- Group related keys together
255+
- Use `common.*` for shared strings (loading, retry, close, etc.)
256+
- Use component-specific prefixes: `package.card.*`, `settings.*`, `nav.*`
257+
258+
### Using i18n-ally (recommended)
259+
260+
We recommend the [i18n-ally](https://marketplace.visualstudio.com/items?itemName=lokalise.i18n-ally) VSCode extension for a better development experience:
261+
262+
- Inline translation previews in your code
263+
- Auto-completion for translation keys
264+
- Missing translation detection
265+
- Easy navigation to translation files
266+
267+
The extension is included in our workspace recommendations, so VSCode should prompt you to install it.
268+
269+
### Adding a new locale
270+
271+
1. Create a new JSON file in `i18n/locales/` (e.g., `fr.json`)
272+
2. Add the locale to `nuxt.config.ts`:
273+
274+
```typescript
275+
i18n: {
276+
locales: [
277+
{ code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
278+
{ code: 'fr', language: 'fr-FR', name: 'Francais', file: 'fr.json' },
279+
],
280+
}
281+
```
282+
283+
3. Translate all keys from `en.json`
284+
285+
### Formatting with locale
286+
287+
When formatting numbers or dates that should respect the user's locale, pass the locale:
288+
289+
```typescript
290+
const { locale } = useI18n()
291+
const formatted = formatNumber(12345, locale.value) // "12,345" in en-US
292+
```
293+
211294
## Testing
212295

213296
### Unit tests

app/components/AppFooter.vue

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,35 +85,35 @@ onMounted(() => {
8585
>
8686
<div class="container py-2 sm:py-6 flex flex-col gap-1 sm:gap-3 text-fg-subtle text-sm">
8787
<div class="flex flex-row items-center justify-between gap-2 sm:gap-4">
88-
<p class="font-mono m-0 hidden sm:block">a better browser for the npm registry</p>
88+
<p class="font-mono m-0 hidden sm:block">{{ $t('tagline') }}</p>
8989
<!-- On mobile, show disclaimer here instead of tagline -->
90-
<p class="text-xs text-fg-muted m-0 sm:hidden">not affiliated with npm, Inc.</p>
90+
<p class="text-xs text-fg-muted m-0 sm:hidden">{{ $t('non_affiliation_disclaimer') }}</p>
9191
<div class="flex items-center gap-4 sm:gap-6">
9292
<a
9393
href="https://repo.npmx.dev"
9494
rel="noopener noreferrer"
9595
class="link-subtle font-mono text-xs min-h-11 min-w- flex items-center"
9696
>
97-
source
97+
{{ $t('footer.source') }}
9898
</a>
9999
<a
100100
href="https://social.npmx.dev"
101101
rel="noopener noreferrer"
102102
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
103103
>
104-
social
104+
{{ $t('footer.social') }}
105105
</a>
106106
<a
107107
href="https://chat.npmx.dev"
108108
rel="noopener noreferrer"
109109
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
110110
>
111-
chat
111+
{{ $t('footer.chat') }}
112112
</a>
113113
</div>
114114
</div>
115115
<p class="text-xs text-fg-muted text-center sm:text-left m-0 hidden sm:block">
116-
npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.
116+
{{ $t('trademark_disclaimer') }}
117117
</p>
118118
</div>
119119
</footer>

app/components/AppHeader.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const { isConnected, npmUser } = useConnector()
2121
<NuxtLink
2222
v-if="showLogo"
2323
to="/"
24-
aria-label="npmx home"
24+
:aria-label="$t('header.home')"
2525
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
2626
>
2727
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx
@@ -38,7 +38,7 @@ const { isConnected, npmUser } = useConnector()
3838
class="link-subtle font-mono text-sm inline-flex items-center gap-2"
3939
aria-keyshortcuts="/"
4040
>
41-
search
41+
{{ $t('nav.search') }}
4242
<kbd
4343
class="hidden sm:inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded"
4444
aria-hidden="true"
@@ -74,7 +74,7 @@ const { isConnected, npmUser } = useConnector()
7474
target="_blank"
7575
rel="noopener noreferrer"
7676
class="link-subtle"
77-
aria-label="GitHub repository"
77+
:aria-label="$t('header.github')"
7878
>
7979
<span class="i-carbon-logo-github w-5 h-5" aria-hidden="true" />
8080
</a>

app/components/ClaimPackageModal.vue

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const props = defineProps<{
88
99
const open = defineModel<boolean>('open', { default: false })
1010
11+
const { t } = useI18n()
12+
1113
const {
1214
isConnected,
1315
state,
@@ -32,7 +34,7 @@ async function checkAvailability() {
3234
try {
3335
checkResult.value = await checkPackageName(props.packageName)
3436
} catch (err) {
35-
publishError.value = err instanceof Error ? err.message : 'Failed to check name availability'
37+
publishError.value = err instanceof Error ? err.message : t('claim.modal.failed_to_check')
3638
} finally {
3739
isChecking.value = false
3840
}
@@ -82,7 +84,7 @@ async function handleClaim() {
8284
connectorModalOpen.value = true
8385
}
8486
} catch (err) {
85-
publishError.value = err instanceof Error ? err.message : 'Failed to claim package'
87+
publishError.value = err instanceof Error ? err.message : t('claim.modal.failed_to_claim')
8688
} finally {
8789
isPublishing.value = false
8890
}
@@ -141,7 +143,7 @@ const connectorModalOpen = shallowRef(false)
141143
<button
142144
type="button"
143145
class="absolute inset-0 bg-black/60 cursor-default"
144-
aria-label="Close modal"
146+
:aria-label="$t('claim.modal.close_modal')"
145147
@click="open = false"
146148
/>
147149

@@ -155,12 +157,12 @@ const connectorModalOpen = shallowRef(false)
155157
<div class="p-6">
156158
<div class="flex items-center justify-between mb-6">
157159
<h2 id="claim-modal-title" class="font-mono text-lg font-medium">
158-
Claim Package Name
160+
{{ $t('claim.modal.title') }}
159161
</h2>
160162
<button
161163
type="button"
162164
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"
163-
aria-label="Close"
165+
:aria-label="$t('claim.modal.close')"
164166
@click="open = false"
165167
>
166168
<span class="i-carbon-close block w-5 h-5" aria-hidden="true" />
@@ -169,7 +171,7 @@ const connectorModalOpen = shallowRef(false)
169171

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

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

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

194195
<div class="flex gap-3">
@@ -197,14 +198,14 @@ const connectorModalOpen = shallowRef(false)
197198
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"
198199
@click="open = false"
199200
>
200-
View Package
201+
{{ $t('claim.modal.view_package') }}
201202
</NuxtLink>
202203
<button
203204
type="button"
204205
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"
205206
@click="open = false"
206207
>
207-
Close
208+
{{ $t('claim.modal.close') }}
208209
</button>
209210
</div>
210211
</div>
@@ -222,7 +223,7 @@ const connectorModalOpen = shallowRef(false)
222223
class="p-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-md"
223224
role="alert"
224225
>
225-
<p class="font-medium mb-1">Invalid package name:</p>
226+
<p class="font-medium mb-1">{{ $t('claim.modal.invalid_name') }}</p>
226227
<ul class="list-disc list-inside space-y-1">
227228
<li v-for="err in checkResult.validationErrors" :key="err">{{ err }}</li>
228229
</ul>
@@ -234,7 +235,7 @@ const connectorModalOpen = shallowRef(false)
234235
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
235236
role="alert"
236237
>
237-
<p class="font-medium mb-1">Warnings:</p>
238+
<p class="font-medium mb-1">{{ $t('common.warnings') }}</p>
238239
<ul class="list-disc list-inside space-y-1">
239240
<li v-for="warn in checkResult.validationWarnings" :key="warn">{{ warn }}</li>
240241
</ul>
@@ -250,15 +251,15 @@ const connectorModalOpen = shallowRef(false)
250251
class="i-carbon-checkmark-filled text-green-500 w-5 h-5"
251252
aria-hidden="true"
252253
/>
253-
<p class="font-mono text-sm text-fg">This name is available!</p>
254+
<p class="font-mono text-sm text-fg">{{ $t('claim.modal.available') }}</p>
254255
</div>
255256

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

@@ -277,9 +278,9 @@ const connectorModalOpen = shallowRef(false)
277278
class="text-sm font-medium mb-3"
278279
>
279280
<span v-if="hasDangerousSimilarPackages">
280-
Similar packages exist - npm may reject this name:
281+
{{ $t('claim.modal.similar_warning') }}
281282
</span>
282-
<span v-else> Related packages: </span>
283+
<span v-else>{{ $t('claim.modal.related') }}</span>
283284
</p>
284285
<ul class="space-y-2">
285286
<li
@@ -331,13 +332,14 @@ const connectorModalOpen = shallowRef(false)
331332
v-if="!isScoped"
332333
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
333334
>
334-
<p class="font-medium mb-1">Consider using a scoped package instead</p>
335+
<p class="font-medium mb-1">{{ $t('claim.modal.scope_warning_title') }}</p>
335336
<p class="text-xs text-yellow-400/80">
336-
Unscoped package names are a shared resource. Only claim a name if you intend to
337-
publish and maintain a package. For personal or organizational projects, use a
338-
scoped name like
339-
<code class="font-mono">@{{ npmUser || 'username' }}/{{ packageName }}</code
340-
>.
337+
{{
338+
$t('claim.modal.scope_warning_text', {
339+
username: npmUser || 'username',
340+
name: packageName,
341+
})
342+
}}
341343
</p>
342344
</div>
343345

@@ -346,29 +348,29 @@ const connectorModalOpen = shallowRef(false)
346348
<div
347349
class="p-3 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-md"
348350
>
349-
<p>Connect to the local connector to claim this package name.</p>
351+
<p>{{ $t('claim.modal.connect_required') }}</p>
350352
</div>
351353
<button
352354
type="button"
353355
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"
354356
@click="connectorModalOpen = true"
355357
>
356-
Connect to Connector
358+
{{ $t('claim.modal.connect_button') }}
357359
</button>
358360
</div>
359361

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

366368
<!-- Expandable package.json preview -->
367369
<details class="border border-border rounded-md overflow-hidden">
368370
<summary
369371
class="px-3 py-2 text-sm text-fg-muted bg-bg-subtle cursor-pointer hover:text-fg transition-colors select-none"
370372
>
371-
Preview package.json
373+
{{ $t('claim.modal.preview_json') }}
372374
</summary>
373375
<pre class="p-3 text-xs font-mono text-fg-muted bg-[#0d0d0d] overflow-x-auto">{{
374376
JSON.stringify(previewPackageJson, null, 2)
@@ -381,7 +383,9 @@ const connectorModalOpen = shallowRef(false)
381383
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"
382384
@click="handleClaim"
383385
>
384-
{{ isPublishing ? 'Publishing…' : 'Claim Package Name' }}
386+
{{
387+
isPublishing ? $t('claim.modal.publishing') : $t('claim.modal.claim_button')
388+
}}
385389
</button>
386390
</div>
387391
</div>
@@ -393,7 +397,7 @@ const connectorModalOpen = shallowRef(false)
393397
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"
394398
@click="open = false"
395399
>
396-
Close
400+
{{ $t('claim.modal.close') }}
397401
</button>
398402
</div>
399403

@@ -410,7 +414,7 @@ const connectorModalOpen = shallowRef(false)
410414
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"
411415
@click="checkAvailability"
412416
>
413-
Retry
417+
{{ $t('claim.modal.retry') }}
414418
</button>
415419
</div>
416420
</div>

0 commit comments

Comments
 (0)