From c04506229669cd3dc3fdc8aa7a89518b5855f645 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 16:26:43 -0500 Subject: [PATCH 1/4] fix: route dependency version ranges to pkg page with semver filter instead of 404 Dependency links with version ranges (e.g. "^18.0.0 || ^19.0.0", ">15 <=16.0.2") previously navigated to a non-existent version page and 404'd. Now `packageRoute()` distinguishes exact versions from ranges: exact versions link to the version page, while ranges link to the package page with `?semver=#versions`, pre-populating the existing "Filter by semver" input. Closes #1120 --- app/components/Package/Versions.vue | 4 +- app/utils/router.ts | 20 ++++- test/unit/app/utils/router.spec.ts | 124 ++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 test/unit/app/utils/router.spec.ts diff --git a/app/components/Package/Versions.vue b/app/components/Package/Versions.vue index ceb19705a..d9d35979e 100644 --- a/app/components/Package/Versions.vue +++ b/app/components/Package/Versions.vue @@ -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)) diff --git a/app/utils/router.ts b/app/utils/router.ts index 1b8ce5d40..2c2530663 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -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', } } diff --git a/test/unit/app/utils/router.spec.ts b/test/unit/app/utils/router.spec.ts new file mode 100644 index 000000000..9ee12cb96 --- /dev/null +++ b/test/unit/app/utils/router.spec.ts @@ -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' }, + }) + }) +}) From 1e71cb6890fa7dce2e69b0e8cffc202540966185 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 16:30:22 -0500 Subject: [PATCH 2/4] feat: support `pkg@version` format in search Searching for `esbuild@0.25.12` or `@angular/core@^18` and pressing Enter now navigates directly to the package version page (or semver filter for ranges) instead of returning no results. Since in the previous commit I added support for linking to the package page with the a pre-filled semver version specifier in the query string to populate the version filter, this supports both exact versions and semver ranges in the search input. Closes #1416 --- app/pages/search.vue | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/pages/search.vue b/app/pages/search.vue index e71faaf25..cf2c969d8 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -422,6 +422,16 @@ function focusElement(el: HTMLElement) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } +// Parse "pkg@version" from search input (e.g. "esbuild@0.25.12", "@angular/core@^18") +function parsePackageAtVersion(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 } +} + // Navigate to package page async function navigateToPackage(packageName: string) { await navigateTo(packageRoute(packageName)) @@ -460,6 +470,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 } = parsePackageAtVersion(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) { From 99771719709d1067d61cdec4cb19d8118144776c Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 17:02:45 -0500 Subject: [PATCH 3/4] refactor: extract parsePackageSpecifier as a shared util --- app/pages/search.vue | 13 +- modules/runtime/server/cache.ts | 39 +---- shared/utils/parse-package-param.ts | 25 +++ .../shared/utils/parse-package-param.spec.ts | 146 +++++------------- 4 files changed, 70 insertions(+), 153 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index cf2c969d8..01fe82a3e 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -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() @@ -422,16 +423,6 @@ function focusElement(el: HTMLElement) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } -// Parse "pkg@version" from search input (e.g. "esbuild@0.25.12", "@angular/core@^18") -function parsePackageAtVersion(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 } -} - // Navigate to package page async function navigateToPackage(packageName: string) { await navigateTo(packageRoute(packageName)) @@ -471,7 +462,7 @@ function handleResultsKeydown(e: KeyboardEvent) { if (!inputValue) return // Handle "pkg@version" format (e.g. "esbuild@0.25.12", "@angular/core@^18") - const { name, version } = parsePackageAtVersion(inputValue) + const { name, version } = parsePackageSpecifier(inputValue) if (version) { return navigateTo(packageRoute(name, version)) } diff --git a/modules/runtime/server/cache.ts b/modules/runtime/server/cache.ts index 9e35b46b1..d931cb3d9 100644 --- a/modules/runtime/server/cache.ts +++ b/modules/runtime/server/cache.ts @@ -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' /** @@ -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 { @@ -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', diff --git a/shared/utils/parse-package-param.ts b/shared/utils/parse-package-param.ts index ef58da172..7775d15bd 100644 --- a/shared/utils/parse-package-param.ts +++ b/shared/utils/parse-package-param.ts @@ -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') diff --git a/test/unit/shared/utils/parse-package-param.spec.ts b/test/unit/shared/utils/parse-package-param.spec.ts index eb27faa6d..bf6cc9943 100644 --- a/test/unit/shared/utils/parse-package-param.spec.ts +++ b/test/unit/shared/utils/parse-package-param.spec.ts @@ -1,126 +1,62 @@ import { describe, expect, it } from 'vitest' -import { parsePackageParam } from '../../../../shared/utils/parse-package-param' +import { parsePackageSpecifier } from '../../../../shared/utils/parse-package-param' -describe('parsePackageParam', () => { - describe('unscoped packages', () => { - it('parses package name without version', () => { - const result = parsePackageParam('vue') - expect(result).toEqual({ - packageName: 'vue', - version: undefined, - rest: [], - }) - }) - - it('parses package name with version', () => { - const result = parsePackageParam('vue/v/3.4.0') - expect(result).toEqual({ - packageName: 'vue', - version: '3.4.0', - rest: [], - }) - }) - - it('parses package name with prerelease version', () => { - const result = parsePackageParam('nuxt/v/4.0.0-rc.1') - expect(result).toEqual({ - packageName: 'nuxt', - version: '4.0.0-rc.1', - rest: [], - }) - }) - - it('parses package name with version and file path', () => { - const result = parsePackageParam('vue/v/3.4.0/src/index.ts') - expect(result).toEqual({ - packageName: 'vue', - version: '3.4.0', - rest: ['src', 'index.ts'], - }) +describe('parsePackageSpecifier', () => { + it('parses unscoped package with exact version', () => { + expect(parsePackageSpecifier('esbuild@0.25.12')).toEqual({ + name: 'esbuild', + version: '0.25.12', }) + }) - it('parses package name with version and nested file path', () => { - const result = parsePackageParam('lodash/v/4.17.21/lib/fp/map.js') - expect(result).toEqual({ - packageName: 'lodash', - version: '4.17.21', - rest: ['lib', 'fp', 'map.js'], - }) + it('parses unscoped package with caret range', () => { + expect(parsePackageSpecifier('react@^18.0.0')).toEqual({ + name: 'react', + version: '^18.0.0', }) }) - describe('scoped packages', () => { - it('parses scoped package name without version', () => { - const result = parsePackageParam('@nuxt/kit') - expect(result).toEqual({ - packageName: '@nuxt/kit', - version: undefined, - rest: [], - }) + it('parses scoped package with exact version', () => { + expect(parsePackageSpecifier('@angular/core@18.0.0')).toEqual({ + name: '@angular/core', + version: '18.0.0', }) + }) - it('parses scoped package name with version', () => { - const result = parsePackageParam('@nuxt/kit/v/1.0.0') - expect(result).toEqual({ - packageName: '@nuxt/kit', - version: '1.0.0', - rest: [], - }) + it('parses scoped package with range', () => { + expect(parsePackageSpecifier('@angular/core@^18')).toEqual({ + name: '@angular/core', + version: '^18', }) + }) - it('parses scoped package name with version and file path', () => { - const result = parsePackageParam('@vue/compiler-sfc/v/3.5.0/dist/index.d.ts') - expect(result).toEqual({ - packageName: '@vue/compiler-sfc', - version: '3.5.0', - rest: ['dist', 'index.d.ts'], - }) - }) + it('returns name only for unscoped package without version', () => { + expect(parsePackageSpecifier('esbuild')).toEqual({ name: 'esbuild' }) + }) - it('parses deeply nested scoped packages', () => { - const result = parsePackageParam('@types/node/v/22.0.0') - expect(result).toEqual({ - packageName: '@types/node', - version: '22.0.0', - rest: [], - }) - }) + it('returns name only for scoped package without version', () => { + expect(parsePackageSpecifier('@angular/core')).toEqual({ name: '@angular/core' }) }) - describe('edge cases', () => { - it('handles package name that looks like a version marker', () => { - // Package named "v" shouldn't be confused with version separator - const result = parsePackageParam('v') - expect(result).toEqual({ - packageName: 'v', - version: undefined, - rest: [], - }) - }) + it('returns name only for bare scope', () => { + expect(parsePackageSpecifier('@angular')).toEqual({ name: '@angular' }) + }) - it('handles version segment without actual version', () => { - // "v" at the end without a version after it - const result = parsePackageParam('vue/v') - expect(result).toEqual({ - packageName: 'vue/v', - version: undefined, - rest: [], - }) - }) + it('handles trailing @ with no version', () => { + expect(parsePackageSpecifier('esbuild@')).toEqual({ name: 'esbuild@' }) + }) - it('handles package with "v" in the name followed by version', () => { - const result = parsePackageParam('vueuse/v/12.0.0') - expect(result).toEqual({ - packageName: 'vueuse', - version: '12.0.0', - rest: [], - }) + it('parses version with union range', () => { + expect(parsePackageSpecifier('@angular/core@^18 || ^19')).toEqual({ + name: '@angular/core', + version: '^18 || ^19', }) + }) - it('handles empty rest when file path is empty', () => { - const result = parsePackageParam('react/v/18.2.0') - expect(result.rest).toEqual([]) - expect(result.rest.length).toBe(0) + it('parses dist-tag as version', () => { + expect(parsePackageSpecifier('nuxt@latest')).toEqual({ + name: 'nuxt', + version: 'latest', }) }) }) From 2db1c4d944e572acd9d948c00dade198bc57de5c Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 17:03:15 -0500 Subject: [PATCH 4/4] test: add e2e test for package@version search navigation --- test/e2e/search-at-version.spec.ts | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 test/e2e/search-at-version.spec.ts diff --git a/test/e2e/search-at-version.spec.ts b/test/e2e/search-at-version.spec.ts new file mode 100644 index 000000000..3c285c09c --- /dev/null +++ b/test/e2e/search-at-version.spec.ts @@ -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$/) + }) +})