Skip to content
Open
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
4 changes: 2 additions & 2 deletions app/components/Package/Versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ const effectiveCurrentVersion = computed(
() => props.selectedVersion ?? props.distTags.latest ?? undefined,
)

// Semver range filter
const semverFilter = ref('')
// Semver range filter (initialized from ?semver= query param if present)
const semverFilter = ref((typeof route.query.semver === 'string' ? route.query.semver : '') || '')
// Collect all known versions: initial props + dynamically loaded ones
const allKnownVersions = computed(() => {
const versions = new Set(Object.keys(props.versions))
Expand Down
7 changes: 7 additions & 0 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { onKeyDown } from '@vueuse/core'
import { debounce } from 'perfect-debounce'
import { isValidNewPackageName } from '~/utils/package-name'
import { isPlatformSpecificPackage } from '~/utils/platform-packages'
import { parsePackageSpecifier } from '#shared/utils/parse-package-param'
import { normalizeSearchParam } from '#shared/utils/url'

const route = useRoute()
Expand Down Expand Up @@ -470,6 +471,12 @@ function handleResultsKeydown(e: KeyboardEvent) {
const inputValue = (document.activeElement as HTMLInputElement).value.trim()
if (!inputValue) return

// Handle "pkg@version" format (e.g. "esbuild@0.25.12", "@angular/core@^18")
const { name, version } = parsePackageSpecifier(inputValue)
if (version) {
return navigateTo(packageRoute(name, version))
}

// Check if first result matches the input value exactly
const firstResult = displayResults.value[0]
if (firstResult?.package.name === inputValue) {
Expand Down
20 changes: 18 additions & 2 deletions app/utils/router.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import type { RouteLocationRaw } from 'vue-router'
import { valid as isValidSingleVersion } from 'semver'

export function packageRoute(packageName: string, version?: string | null): RouteLocationRaw {
const [org, name = ''] = packageName.startsWith('@') ? packageName.split('/') : ['', packageName]

if (version) {
if (isValidSingleVersion(version)) {
return {
name: 'package-version',
params: {
org,
name,
version,
},
}
}

// If we have a version param but it isn't a *specific, single version* (e.g. 1.2.3), treat it
// as a semver specifier (e.g. ^1.2.3 or * or 3||4 or >3<=5) and route to the package page with
// the semver query param, which will pre-populate the version selector and show matching versions.
return {
name: 'package-version',
name: 'package',
params: {
org,
name,
version,
},
query: { semver: version },
hash: '#versions',
}
}

Expand Down
19 changes: 19 additions & 0 deletions docs/content/2.guide/1.features.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ navigation:

npmx.dev provides a comprehensive set of features for browsing npm packages.

## Search

### Find packages

Type a package name in the search bar to find it. Press `Enter` to navigate directly to an exact match.

### Jump to a specific version

Type `package@version` in the search bar and press `Enter` to go directly to that version:

| Input | Navigates to |
| -------------------- | ---------------------------------------------------------- |
| `vue@3.4.0` | Version page for vue 3.4.0 |
| `@nuxt/kit@^3.0.0` | Package page with versions matching `^3.0.0` pre-filtered |
| `react@^18 \|\| ^19` | Package page with versions matching the union pre-filtered |
| `nuxt@latest` | Package page with `latest` dist-tag filter |

When the version is an exact version number (e.g. `3.4.0`), you'll land on that version's page. When it's a [semver range](/guide/semver-ranges) (e.g. `^3.0.0`), you'll land on the package page with the version filter pre-populated.

## Browse packages

### View package details
Expand Down
4 changes: 4 additions & 0 deletions docs/content/2.guide/2.keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ These shortcuts work anywhere on the site. Press `/` from any page to quickly se
| `Arrow Up` / `Arrow Down` | Move through results |
| `Enter` | Open selected package |

::tip
Type `package@version` (e.g. `vue@3.4.0` or `react@^18`) and press `Enter` to jump directly to that version or filter matching versions.
::

## Package page

| Key | Action |
Expand Down
12 changes: 12 additions & 0 deletions docs/content/2.guide/3.url-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ npmx.dev also supports shorter, cleaner URLs:
| `/@org` | [`/@nuxt`](https://npmx.dev/@nuxt) |
| `/~username` | [`/~sindresorhus`](https://npmx.dev/~sindresorhus) |

## Filter versions via URL

Append `?semver=<range>#versions` to any package URL to pre-populate the version filter:

| URL | Effect |
| --------------------------------------------- | --------------------------------------------- |
| `/package/vue?semver=^3.0.0#versions` | Shows vue versions matching `^3.0.0` |
| `/package/react?semver=^18 \|\| ^19#versions` | Shows react versions matching `^18 \|\| ^19` |
| `/package/nuxt?semver=latest#versions` | Shows nuxt versions matching the `latest` tag |

This is the same filter used on the [semver ranges](/guide/semver-ranges) page. Dependency links with version ranges (e.g. `^18.0.0 || ^19.0.0`) automatically link to the package page with this filter applied.

## Understand URL limitations

Some npm URLs are not yet supported:
Expand Down
9 changes: 9 additions & 0 deletions docs/content/2.guide/5.semver-ranges.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ Type the exact version number, like `5.3.1`, to check if it exists.

Type `>=3.0.0-alpha.0` to find alpha, beta, and release candidate versions for a major release.

## Use semver ranges across npmx

Semver ranges appear in several places on npmx:

- **Version filter** - Type a range into the filter on any package page to find matching versions
- **Dependency links** - Dependencies with version ranges (like `^18.0.0 || ^19.0.0`) link to the package page with the filter pre-populated
- **Search bar** - Type `package@range` (e.g. `react@^18`) and press `Enter` to jump to the package with versions pre-filtered
- **URL query param** - Add `?semver=^3.0.0#versions` to any package URL to share a filtered view

## Learn more

The full semver range specification is documented at [node-semver](https://github.com/npm/node-semver#ranges).
39 changes: 2 additions & 37 deletions modules/runtime/server/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import process from 'node:process'
import type { CachedFetchResult } from '#shared/utils/fetch-cache-config'
import { parsePackageSpecifier } from '#shared/utils/parse-package-param'
import { createFetch } from 'ofetch'

/**
Expand Down Expand Up @@ -64,42 +65,6 @@ function getFixturePath(type: FixtureType, name: string): string {
return `${dir}:${filename.replace(/\//g, ':')}`
}

/**
* Parse a scoped package name with optional version.
* Handles formats like: @scope/name, @scope/name@version, name, name@version
*/
function parseScopedPackageWithVersion(input: string): { name: string; version?: string } {
if (input.startsWith('@')) {
// Scoped package: @scope/name or @scope/name@version
const slashIndex = input.indexOf('/')
if (slashIndex === -1) {
// Invalid format like just "@scope"
return { name: input }
}
const afterSlash = input.slice(slashIndex + 1)
const atIndex = afterSlash.indexOf('@')
if (atIndex === -1) {
// @scope/name (no version)
return { name: input }
}
// @scope/name@version
return {
name: input.slice(0, slashIndex + 1 + atIndex),
version: afterSlash.slice(atIndex + 1),
}
}

// Unscoped package: name or name@version
const atIndex = input.indexOf('@')
if (atIndex === -1) {
return { name: input }
}
return {
name: input.slice(0, atIndex),
version: input.slice(atIndex + 1),
}
}

function getMockForUrl(url: string): MockResult | null {
let urlObj: URL
try {
Expand Down Expand Up @@ -174,7 +139,7 @@ function getMockForUrl(url: string): MockResult | null {
const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/)
if (packageMatch?.[1]) {
const pkgWithVersion = packageMatch[1]
const parsed = parseScopedPackageWithVersion(pkgWithVersion)
const parsed = parsePackageSpecifier(pkgWithVersion)
return {
data: {
type: 'npm',
Expand Down
25 changes: 25 additions & 0 deletions shared/utils/parse-package-param.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ export interface ParsedPackageParams {
* // { packageName: '@nuxt/kit', version: '1.0.0', rest: ['src', 'index.ts'] }
* ```
*/
/**
* Parse a "pkg@version" specifier string into name and optional version.
* Handles scoped packages correctly (the scope `@` is not treated as a version separator).
*
* @example
* ```ts
* parsePackageSpecifier('esbuild@0.25.12')
* // { name: 'esbuild', version: '0.25.12' }
*
* parsePackageSpecifier('@angular/core@^18')
* // { name: '@angular/core', version: '^18' }
*
* parsePackageSpecifier('react')
* // { name: 'react' }
* ```
*/
export function parsePackageSpecifier(input: string): { name: string; version?: string } {
const atIndex = input.startsWith('@') ? input.indexOf('@', 1) : input.indexOf('@')
if (atIndex > 0) {
const version = input.slice(atIndex + 1)
if (version) return { name: input.slice(0, atIndex), version }
}
return { name: input }
}

export function parsePackageParam(pkgParam: string): ParsedPackageParams {
const segments = pkgParam.split('/')
const vIndex = segments.indexOf('v')
Expand Down
81 changes: 81 additions & 0 deletions test/e2e/search-at-version.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, test } from './test-utils'

test.describe('Search pkg@version navigation', () => {
test('esbuild@0.25.12 → navigates to exact version page', async ({ page, goto }) => {
await goto('/search', { waitUntil: 'hydration' })

const searchInput = page.locator('input[type="search"]')
await searchInput.fill('esbuild@0.25.12')
await page.keyboard.press('Enter')

await expect(page).toHaveURL(/\/package\/esbuild\/v\/0\.25\.12/)
})

test('@angular/core@18.0.0 → navigates to scoped exact version page', async ({ page, goto }) => {
await goto('/search', { waitUntil: 'hydration' })

const searchInput = page.locator('input[type="search"]')
await searchInput.fill('@angular/core@18.0.0')
await page.keyboard.press('Enter')

await expect(page).toHaveURL(/\/package\/@angular\/core\/v\/18\.0\.0/)
})

test('react@^18.0.0 → navigates to package page with semver filter', async ({ page, goto }) => {
await goto('/search', { waitUntil: 'hydration' })

const searchInput = page.locator('input[type="search"]')
await searchInput.fill('react@^18.0.0')
await page.keyboard.press('Enter')

await expect(page).toHaveURL(/\/package\/react\?semver=/)
await expect(page).toHaveURL(/#versions/)
})

test('@angular/core@^18 || ^19 → navigates to package page with semver filter', async ({
page,
goto,
}) => {
await goto('/search', { waitUntil: 'hydration' })

const searchInput = page.locator('input[type="search"]')
await searchInput.fill('@angular/core@^18 || ^19')
await page.keyboard.press('Enter')

await expect(page).toHaveURL(/\/package\/@angular\/core\?semver=/)
await expect(page).toHaveURL(/#versions/)
})

test('nuxt@latest → navigates to package page with semver filter for dist-tag', async ({
page,
goto,
}) => {
await goto('/search', { waitUntil: 'hydration' })

const searchInput = page.locator('input[type="search"]')
await searchInput.fill('nuxt@latest')
await page.keyboard.press('Enter')

await expect(page).toHaveURL(/\/package\/nuxt\?semver=latest/)
await expect(page).toHaveURL(/#versions/)
})

test('plain package name without @ version → does not trigger version navigation', async ({
page,
goto,
}) => {
await goto('/search?q=vue', { waitUntil: 'hydration' })

// Wait for search results to load
await expect(page.locator('text=/found \\d+|showing \\d+/i').first()).toBeVisible({
timeout: 15000,
})

const searchInput = page.locator('input[type="search"]')
await searchInput.focus()
await page.keyboard.press('Enter')

// Should navigate to the package page (exact match), not a version page
await expect(page).toHaveURL(/\/package\/vue$/)
})
})
Loading
Loading