Skip to content
Draft
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 @@ -110,8 +110,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 @@ -460,6 +461,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
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$/)
})
})
124 changes: 124 additions & 0 deletions test/unit/app/utils/router.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { diffRoute, packageRoute } from '../../../../app/utils/router'

describe('packageRoute', () => {
describe('without version', () => {
it('returns package route for unscoped package', () => {
expect(packageRoute('react')).toEqual({
name: 'package',
params: { org: '', name: 'react' },
})
})

it('returns package route for scoped package', () => {
expect(packageRoute('@angular/core')).toEqual({
name: 'package',
params: { org: '@angular', name: 'core' },
})
})
})

describe('with exact version', () => {
it('returns version route for unscoped package', () => {
expect(packageRoute('react', '18.0.0')).toEqual({
name: 'package-version',
params: { org: '', name: 'react', version: '18.0.0' },
})
})

it('returns version route for scoped package', () => {
expect(packageRoute('@angular/core', '18.0.0')).toEqual({
name: 'package-version',
params: { org: '@angular', name: 'core', version: '18.0.0' },
})
})

it('returns version route for prerelease version', () => {
expect(packageRoute('vue', '3.5.0-beta.1')).toEqual({
name: 'package-version',
params: { org: '', name: 'vue', version: '3.5.0-beta.1' },
})
})
})

describe('with version range', () => {
it('returns package route with semver query for caret range', () => {
expect(packageRoute('react', '^18.0.0')).toEqual({
name: 'package',
params: { org: '', name: 'react' },
query: { semver: '^18.0.0' },
hash: '#versions',
})
})

it('returns package route with semver query for tilde range', () => {
expect(packageRoute('react', '~18.2.0')).toEqual({
name: 'package',
params: { org: '', name: 'react' },
query: { semver: '~18.2.0' },
hash: '#versions',
})
})

it('returns package route with semver query for union range', () => {
expect(packageRoute('@angular/core', '^18.0.0 || ^19.0.0 || ^20.0.0')).toEqual({
name: 'package',
params: { org: '@angular', name: 'core' },
query: { semver: '^18.0.0 || ^19.0.0 || ^20.0.0' },
hash: '#versions',
})
})

it('returns package route with semver query for comparator range', () => {
expect(packageRoute('typescript', '>15 <=16.0.2')).toEqual({
name: 'package',
params: { org: '', name: 'typescript' },
query: { semver: '>15 <=16.0.2' },
hash: '#versions',
})
})

it('returns package route with semver query for wildcard', () => {
expect(packageRoute('lodash', '*')).toEqual({
name: 'package',
params: { org: '', name: 'lodash' },
query: { semver: '*' },
hash: '#versions',
})
})

it('returns package route with semver query for dist-tag', () => {
expect(packageRoute('nuxt', 'latest')).toEqual({
name: 'package',
params: { org: '', name: 'nuxt' },
query: { semver: 'latest' },
hash: '#versions',
})
})
})

describe('with null/undefined version', () => {
it('returns package route for null version', () => {
expect(packageRoute('react', null)).toEqual({
name: 'package',
params: { org: '', name: 'react' },
})
})
})
})

describe('diffRoute', () => {
it('returns diff route for unscoped package', () => {
expect(diffRoute('react', '17.0.0', '18.0.0')).toEqual({
name: 'diff',
params: { org: undefined, packageName: 'react', versionRange: '17.0.0...18.0.0' },
})
})

it('returns diff route for scoped package', () => {
expect(diffRoute('@angular/core', '17.0.0', '18.0.0')).toEqual({
name: 'diff',
params: { org: '@angular', packageName: 'core', versionRange: '17.0.0...18.0.0' },
})
})
})
Loading
Loading