Skip to content

Commit 38d6adc

Browse files
committed
Merge branch 'main' into vt/feat-exec
Resolve conflicts: - app/pages/[...package].vue: Keep binary-only detection, add @types install support - test/unit/install-command.spec.ts: Keep comprehensive execute command tests
2 parents 7999f2a + 0d28eb4 commit 38d6adc

113 files changed

Lines changed: 9106 additions & 1472 deletions

File tree

Some content is hidden

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

.oxlintrc.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"no-console": "warn",
1111
"no-await-in-loop": "off",
1212
"unicorn/no-array-sort": "off",
13-
"no-restricted-globals": "error"
13+
"no-restricted-globals": "error",
14+
"typescript/consistent-type-imports": "error"
1415
},
1516
"ignorePatterns": [
1617
".output/**",

.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: 7 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({
@@ -195,6 +198,10 @@ button {
195198
margin-top: 2rem;
196199
margin-bottom: 1rem;
197200
line-height: 1.3;
201+
202+
a {
203+
text-decoration: none;
204+
}
198205
}
199206
200207
/* Visual styling based on original README heading level */
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: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
<script setup lang="ts">
2-
const isMounted = ref(false)
3-
const isVisible = ref(false)
4-
const isScrollable = ref(true)
5-
const lastScrollY = ref(0)
6-
const footerRef = ref<HTMLElement>()
2+
const isMounted = shallowRef(false)
3+
const isVisible = shallowRef(false)
4+
const isScrollable = shallowRef(true)
5+
const lastScrollY = shallowRef(0)
6+
const footerRef = useTemplateRef('footerRef')
77
88
// Check if CSS scroll-state container queries are supported
99
// Once this becomes baseline, we can remove the JS scroll handling entirely
10-
const supportsScrollStateQueries = ref(false)
10+
const supportsScrollStateQueries = useSupported(() => {
11+
return isMounted.value && CSS.supports('container-type', 'scroll-state')
12+
})
1113
1214
function checkScrollable() {
1315
return document.documentElement.scrollHeight > window.innerHeight
@@ -48,26 +50,17 @@ function onResize() {
4850
updateFooterPadding()
4951
}
5052
51-
onMounted(() => {
52-
// Feature detect CSS scroll-state container queries (Chrome 133+)
53-
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
54-
supportsScrollStateQueries.value = CSS.supports('container-type', 'scroll-state')
53+
useEventListener('scroll', onScroll, { passive: true })
54+
useEventListener('resize', onResize, { passive: true })
5555
56+
onMounted(() => {
5657
nextTick(() => {
5758
lastScrollY.value = window.scrollY
5859
isScrollable.value = checkScrollable()
5960
updateFooterPadding()
6061
// Only apply dynamic classes after mount to avoid hydration mismatch
6162
isMounted.value = true
6263
})
63-
64-
window.addEventListener('scroll', onScroll, { passive: true })
65-
window.addEventListener('resize', onResize, { passive: true })
66-
})
67-
68-
onUnmounted(() => {
69-
window.removeEventListener('scroll', onScroll)
70-
window.removeEventListener('resize', onResize)
7164
})
7265
</script>
7366

@@ -92,35 +85,35 @@ onUnmounted(() => {
9285
>
9386
<div class="container py-2 sm:py-6 flex flex-col gap-1 sm:gap-3 text-fg-subtle text-sm">
9487
<div class="flex flex-row items-center justify-between gap-2 sm:gap-4">
95-
<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>
9689
<!-- On mobile, show disclaimer here instead of tagline -->
97-
<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>
9891
<div class="flex items-center gap-4 sm:gap-6">
9992
<a
10093
href="https://repo.npmx.dev"
10194
rel="noopener noreferrer"
10295
class="link-subtle font-mono text-xs min-h-11 min-w- flex items-center"
10396
>
104-
source
97+
{{ $t('footer.source') }}
10598
</a>
10699
<a
107100
href="https://social.npmx.dev"
108101
rel="noopener noreferrer"
109102
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
110103
>
111-
social
104+
{{ $t('footer.social') }}
112105
</a>
113106
<a
114107
href="https://chat.npmx.dev"
115108
rel="noopener noreferrer"
116109
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
117110
>
118-
chat
111+
{{ $t('footer.chat') }}
119112
</a>
120113
</div>
121114
</div>
122115
<p class="text-xs text-fg-muted text-center sm:text-left m-0 hidden sm:block">
123-
npm is a registered trademark of npm, Inc. This site is not affiliated with npm, Inc.
116+
{{ $t('trademark_disclaimer') }}
124117
</p>
125118
</div>
126119
</footer>
@@ -144,7 +137,12 @@ onUnmounted(() => {
144137
z-index: 40;
145138
/* Hidden by default (translated off-screen) */
146139
transform: translateY(100%);
147-
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+
}
148146
}
149147
150148
/* Show footer when user can scroll up (meaning they've scrolled down) */

app/components/AppHeader.vue

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,36 @@ withDefaults(
99
showConnector: true,
1010
},
1111
)
12+
13+
const { isConnected, npmUser } = useConnector()
1214
</script>
1315

1416
<template>
1517
<header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
16-
<nav aria-label="Main navigation" class="container h-14 flex items-center justify-between">
17-
<NuxtLink
18-
v-if="showLogo"
19-
to="/"
20-
aria-label="npmx home"
21-
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
22-
>
23-
<span class="text-fg-subtle"><span style="letter-spacing: -0.2em">.</span>/</span>npmx
24-
</NuxtLink>
25-
<!-- Spacer when logo is hidden -->
26-
<span v-else class="w-1" />
27-
28-
<ul class="flex items-center gap-4 sm:gap-6 list-none m-0 p-0">
29-
<li class="flex">
18+
<nav aria-label="Main navigation" class="container h-14 flex items-center">
19+
<!-- Left: Logo -->
20+
<div class="flex-shrink-0">
21+
<NuxtLink
22+
v-if="showLogo"
23+
to="/"
24+
:aria-label="$t('header.home')"
25+
class="header-logo font-mono text-lg font-medium text-fg hover:text-fg transition-colors duration-200 focus-ring rounded"
26+
>
27+
<span class="text-accent"><span class="-tracking-0.2em">.</span>/</span>npmx
28+
</NuxtLink>
29+
<!-- Spacer when logo is hidden -->
30+
<span v-else class="w-1" />
31+
</div>
32+
33+
<!-- Center: Main nav items -->
34+
<ul class="flex-1 flex items-center justify-center gap-4 sm:gap-6 list-none m-0 p-0">
35+
<li class="flex items-center">
3036
<NuxtLink
3137
to="/search"
3238
class="link-subtle font-mono text-sm inline-flex items-center gap-2"
3339
aria-keyshortcuts="/"
3440
>
35-
search
41+
{{ $t('nav.search') }}
3642
<kbd
3743
class="hidden sm:inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded"
3844
aria-hidden="true"
@@ -41,20 +47,38 @@ withDefaults(
4147
</kbd>
4248
</NuxtLink>
4349
</li>
44-
<li v-if="showConnector" class="flex">
45-
<ConnectorStatus />
50+
51+
<!-- Packages dropdown (when connected) -->
52+
<li v-if="isConnected && npmUser" class="flex items-center">
53+
<HeaderPackagesDropdown :username="npmUser" />
4654
</li>
47-
<li v-else class="flex">
48-
<a
49-
href="https://github.com/npmx-dev/npmx.dev"
50-
rel="noopener noreferrer"
51-
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
52-
>
53-
<span class="i-carbon-logo-github w-4 h-4" />
54-
<span class="hidden sm:inline">github</span>
55-
</a>
55+
56+
<!-- Orgs dropdown (when connected) -->
57+
<li v-if="isConnected && npmUser" class="flex items-center">
58+
<HeaderOrgsDropdown :username="npmUser" />
5659
</li>
5760
</ul>
61+
62+
<!-- Right: User status + GitHub -->
63+
<div class="flex-shrink-0 flex items-center gap-6">
64+
<ClientOnly>
65+
<SettingsMenu />
66+
</ClientOnly>
67+
68+
<div v-if="showConnector">
69+
<ConnectorStatus />
70+
</div>
71+
72+
<a
73+
href="https://github.com/npmx-dev/npmx.dev"
74+
target="_blank"
75+
rel="noopener noreferrer"
76+
class="link-subtle"
77+
:aria-label="$t('header.github')"
78+
>
79+
<span class="i-carbon-logo-github w-5 h-5" aria-hidden="true" />
80+
</a>
81+
</div>
5882
</nav>
5983
</header>
6084
</template>

app/components/AppTooltip.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const props = defineProps<{
66
position?: 'top' | 'bottom' | 'left' | 'right'
77
}>()
88
9-
const isVisible = ref(false)
9+
const isVisible = shallowRef(false)
1010
const tooltipId = useId()
1111
1212
const positionClasses: Record<string, string> = {

0 commit comments

Comments
 (0)