Skip to content

Commit 0ece81e

Browse files
committed
Merge origin/main into feat/fetch-cache
Resolve conflict in useNpmRegistry.ts: - Keep both fetchNpmDownloadsRange (exported for external use) and usePackageWeeklyDownloadEvolution (composable with SWR caching)
2 parents 631fad4 + 0d28eb4 commit 0ece81e

62 files changed

Lines changed: 4333 additions & 764 deletions

Some content is hidden

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

.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/app.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { useEventListener } from '@vueuse/core'
44
const route = useRoute()
55
const router = useRouter()
66
7+
// Initialize accent color before hydration to prevent flash
8+
initAccentOnPrehydrate()
9+
710
const isHomepage = computed(() => route.path === '/')
811
912
useHead({
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script setup lang="ts">
2+
import { useAccentColor } from '~/composables/useSettings'
3+
4+
const { accentColors, selectedAccentColor, setAccentColor } = useAccentColor()
5+
</script>
6+
7+
<template>
8+
<div role="listbox" aria-label="Accent colors" class="flex items-center justify-between">
9+
<button
10+
v-for="color in accentColors"
11+
:key="color.id"
12+
type="button"
13+
role="option"
14+
:aria-selected="selectedAccentColor === color.id"
15+
:aria-label="color.name"
16+
class="size-6 rounded-full transition-transform duration-150 motion-safe:hover:scale-110 focus-ring aria-selected:(ring-2 ring-fg ring-offset-2 ring-offset-bg-subtle)"
17+
:style="{ backgroundColor: color.value }"
18+
@click="setAccentColor(color.id)"
19+
/>
20+
<button
21+
type="button"
22+
aria-label="Clear accent color"
23+
class="size-6 rounded-full transition-transform duration-150 motion-safe:hover:scale-110 focus-ring flex items-center justify-center bg-accent-fallback"
24+
@click="setAccentColor(null)"
25+
>
26+
<span class="i-carbon-error size-4 text-bg" aria-hidden="true" />
27+
</button>
28+
</div>
29+
</template>

app/components/AppFooter.vue

Lines changed: 12 additions & 7 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>
@@ -137,7 +137,12 @@ onMounted(() => {
137137
z-index: 40;
138138
/* Hidden by default (translated off-screen) */
139139
transform: translateY(100%);
140-
transition: transform 0.3s ease-out;
140+
}
141+
142+
@media (prefers-reduced-motion: no-preference) {
143+
.footer-scroll-state {
144+
transition: transform 0.3s ease-out;
145+
}
141146
}
142147
143148
/* Show footer when user can scroll up (meaning they've scrolled down) */

app/components/AppHeader.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ 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
>
27-
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx
27+
<span class="text-accent"><span class="-tracking-0.2em">.</span>/</span>npmx
2828
</NuxtLink>
2929
<!-- Spacer when logo is hidden -->
3030
<span v-else class="w-1" />
@@ -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/ChartModal.vue

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script setup lang="ts">
2+
const open = defineModel<boolean>('open', { default: false })
3+
4+
function handleKeydown(event: KeyboardEvent) {
5+
if (event.key === 'Escape') {
6+
open.value = false
7+
}
8+
}
9+
</script>
10+
11+
<template>
12+
<Teleport to="body">
13+
<Transition
14+
enter-active-class="transition-opacity duration-200"
15+
leave-active-class="transition-opacity duration-200"
16+
enter-from-class="opacity-0"
17+
leave-to-class="opacity-0"
18+
>
19+
<div
20+
v-if="open"
21+
class="fixed inset-0 z-50 flex items-center justify-center p-0 sm:p-4"
22+
@keydown="handleKeydown"
23+
>
24+
<!-- Backdrop -->
25+
<button
26+
type="button"
27+
class="absolute inset-0 bg-black/60 cursor-default"
28+
aria-label="Close modal"
29+
@click="open = false"
30+
/>
31+
32+
<div
33+
class="relative w-full h-full sm:h-auto bg-bg sm:border sm:border-border sm:rounded-lg shadow-xl sm:max-h-[90vh] overflow-y-auto overscroll-contain sm:max-w-3xl"
34+
role="dialog"
35+
aria-modal="true"
36+
aria-labelledby="chart-modal-title"
37+
>
38+
<div class="p-4 sm:p-6">
39+
<div class="flex items-center justify-between mb-4 sm:mb-6">
40+
<h2 id="chart-modal-title" class="font-mono text-lg font-medium">
41+
<slot name="title" />
42+
</h2>
43+
<button
44+
type="button"
45+
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"
46+
aria-label="Close"
47+
@click="open = false"
48+
>
49+
<span class="i-carbon-close block w-5 h-5" aria-hidden="true" />
50+
</button>
51+
</div>
52+
<div class="font-mono text-sm">
53+
<slot />
54+
</div>
55+
</div>
56+
</div>
57+
</div>
58+
</Transition>
59+
</Teleport>
60+
</template>

0 commit comments

Comments
 (0)