From a45ad6581b7d3bcf01ac886d8fa8dd36bcfec028 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 16:26:43 -0500 Subject: [PATCH 01/11] 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 | 22 ++++- test/unit/app/utils/router.spec.ts | 124 ++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 6 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 3baab617ce..c1b1da60fc 100644 --- a/app/components/Package/Versions.vue +++ b/app/components/Package/Versions.vue @@ -105,8 +105,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 : '') || '') // Load all versions when a valid semver filter is entered watch(semverFilter, async newFilter => { diff --git a/app/utils/router.ts b/app/utils/router.ts index ac3c9a46a6..5022e11e6a 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -1,4 +1,5 @@ import type { RouteLocationRaw } from 'vue-router' +import { valid as isValidSingleVersion } from 'semver' import { splitPackageName } from '~/utils/package-name' export function packageRoute( @@ -9,15 +10,28 @@ export function packageRoute( const { org, name } = splitPackageName(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, - // remove spaces to be correctly resolved by router - version: version.replace(/\s+/g, ''), }, - hash, + query: { semver: version }, + hash: 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 0000000000..9ee12cb96a --- /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 538a5fee2559a60529f14ea61c5eea2e256f11ad Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 16:30:22 -0500 Subject: [PATCH 02/11] 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 b635065aa0..0ba0c55733 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -441,6 +441,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)) @@ -494,6 +504,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)) + } + // When instantSearch is off, commit the query so search starts committedQuery.value = inputValue From db110a6adcac142aae79767a4c1f07affdc53a98 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 17:02:45 -0500 Subject: [PATCH 03/11] refactor: extract parsePackageSpecifier as a shared util --- app/pages/search.vue | 13 +- modules/runtime/server/cache.ts | 87 ++++++---- shared/utils/parse-package-param.ts | 25 +++ .../shared/utils/parse-package-param.spec.ts | 162 ++++++------------ 4 files changed, 128 insertions(+), 159 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index 0ba0c55733..000d7aa31e 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' definePageMeta({ @@ -441,16 +442,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)) @@ -505,7 +496,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 780f589e23..f1d5809b23 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' /** @@ -65,42 +66,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 { const urlObj = URL.parse(url) if (!urlObj) return null @@ -137,6 +102,56 @@ function getMockForUrl(url: string): MockResult | null { } } + // npms.io API - return mock package score data + if (host === 'api.npms.io') { + const packageMatch = decodeURIComponent(pathname).match(/^\/v2\/package\/(.+)$/) + if (packageMatch?.[1]) { + return { + data: { + analyzedAt: new Date().toISOString(), + collected: { + metadata: { name: packageMatch[1] }, + }, + score: { + final: 0.75, + detail: { + quality: 0.8, + popularity: 0.7, + maintenance: 0.75, + }, + }, + }, + } + } + } + + // jsdelivr CDN - return 404 for README files, etc. + if (host === 'cdn.jsdelivr.net') { + // Return null data which will cause a 404 - README files are optional + return { data: null } + } + + // jsdelivr data API - return mock file listing + if (host === 'data.jsdelivr.com') { + const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/) + if (packageMatch?.[1]) { + const pkgWithVersion = packageMatch[1] + const parsed = parsePackageSpecifier(pkgWithVersion) + return { + data: { + type: 'npm', + name: parsed.name, + version: parsed.version || 'latest', + files: [ + { name: 'package.json', hash: 'abc123', size: 1000 }, + { name: 'index.js', hash: 'def456', size: 500 }, + { name: 'README.md', hash: 'ghi789', size: 2000 }, + ], + }, + } + } + } + // Gravatar API - return 404 (avatars not needed in tests) if (host === 'www.gravatar.com') { return { data: null } diff --git a/shared/utils/parse-package-param.ts b/shared/utils/parse-package-param.ts index 310fcd2fd0..0a4a12b988 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('/') let 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 dbf2223082..0e4c8a9a5a 100644 --- a/test/unit/shared/utils/parse-package-param.spec.ts +++ b/test/unit/shared/utils/parse-package-param.spec.ts @@ -1,135 +1,73 @@ import { describe, expect, it } from 'vitest' -import { parsePackageParam } from '#shared/utils/parse-package-param' +import { parsePackageParam, 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' }) + }) - it('parses scoped package names whose package segment is literally v', () => { - const result = parsePackageParam('@scope/v/v/1.2.3/dist/index.js') - expect(result).toEqual({ - packageName: '@scope/v', - version: '1.2.3', - rest: ['dist', 'index.js'], - }) - }) + it('returns name only for bare scope', () => { + expect(parsePackageSpecifier('@angular')).toEqual({ name: '@angular' }) }) - 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('handles trailing @ with no version', () => { + expect(parsePackageSpecifier('esbuild@')).toEqual({ name: 'esbuild@' }) + }) - 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('parses version with union range', () => { + expect(parsePackageSpecifier('@angular/core@^18 || ^19')).toEqual({ + name: '@angular/core', + version: '^18 || ^19', }) + }) - 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 dist-tag as version', () => { + expect(parsePackageSpecifier('nuxt@latest')).toEqual({ + name: 'nuxt', + version: 'latest', }) + }) +}) - 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) +describe('parsePackageParam', () => { + it('parses scoped package names whose package segment is literally v', () => { + const result = parsePackageParam('@scope/v/v/1.2.3/dist/index.js') + expect(result).toEqual({ + packageName: '@scope/v', + version: '1.2.3', + rest: ['dist', 'index.js'], }) }) }) From d6a03728d6eaf8b835bd4d819b97c271706dc958 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 17:03:15 -0500 Subject: [PATCH 04/11] 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 0000000000..3c285c09c1 --- /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$/) + }) +}) From dffa4f2d185b93d6107e20589a219328efcf3407 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 1 Mar 2026 16:27:41 -0500 Subject: [PATCH 05/11] docs: document the new version features --- docs/content/2.guide/1.features.md | 19 +++++++++++++++++++ docs/content/2.guide/2.keyboard-shortcuts.md | 4 ++++ docs/content/2.guide/3.url-structure.md | 12 ++++++++++++ docs/content/2.guide/5.semver-ranges.md | 9 +++++++++ 4 files changed, 44 insertions(+) diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index 8e58bff53a..2c12182043 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -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 ### Command palette diff --git a/docs/content/2.guide/2.keyboard-shortcuts.md b/docs/content/2.guide/2.keyboard-shortcuts.md index f0268a2880..f39de2fe9d 100644 --- a/docs/content/2.guide/2.keyboard-shortcuts.md +++ b/docs/content/2.guide/2.keyboard-shortcuts.md @@ -41,6 +41,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 | diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md index 6b8a65f026..11b5d69599 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -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=#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: diff --git a/docs/content/2.guide/5.semver-ranges.md b/docs/content/2.guide/5.semver-ranges.md index 0c77084687..5434c44146 100644 --- a/docs/content/2.guide/5.semver-ranges.md +++ b/docs/content/2.guide/5.semver-ranges.md @@ -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). From 99573d3b89f498f3c2a11ed9a079afec7e4a0b04 Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:51:28 +0300 Subject: [PATCH 06/11] fix: consolidate parsePackageSpec --- app/pages/search.vue | 4 +- shared/utils/parse-package-param.ts | 25 --- .../shared/utils/parse-package-param.spec.ts | 157 ++++++++++++++++-- 3 files changed, 147 insertions(+), 39 deletions(-) diff --git a/app/pages/search.vue b/app/pages/search.vue index eb9366f97b..b89dd57881 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -5,7 +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 { parsePackageSpec } from '#shared/utils/parse-package-param' import { normalizeSearchParam } from '#shared/utils/url' definePageMeta({ @@ -501,7 +501,7 @@ function handleResultsKeydown(e: KeyboardEvent) { if (!inputValue) return // Handle "pkg@version" format (e.g. "esbuild@0.25.12", "@angular/core@^18") - const { name, version } = parsePackageSpecifier(inputValue) + const { name, version } = parsePackageSpec(inputValue) if (version) { return navigateTo(packageRoute(name, version)) } diff --git a/shared/utils/parse-package-param.ts b/shared/utils/parse-package-param.ts index 21a29114f6..7ea86c73b8 100644 --- a/shared/utils/parse-package-param.ts +++ b/shared/utils/parse-package-param.ts @@ -35,31 +35,6 @@ 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('/') let 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 0e4c8a9a5a..1c3938857f 100644 --- a/test/unit/shared/utils/parse-package-param.spec.ts +++ b/test/unit/shared/utils/parse-package-param.spec.ts @@ -1,60 +1,193 @@ import { describe, expect, it } from 'vitest' -import { parsePackageParam, parsePackageSpecifier } from '#shared/utils/parse-package-param' +import { parsePackageParam, parsePackageSpec } from '#shared/utils/parse-package-param' -describe('parsePackageSpecifier', () => { +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'], + }) + }) + + 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'], + }) + }) + }) + + 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 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 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('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('parses scoped package names whose package segment is literally v', () => { + const result = parsePackageParam('@scope/v/v/1.2.3/dist/index.js') + expect(result).toEqual({ + packageName: '@scope/v', + version: '1.2.3', + rest: ['dist', 'index.js'], + }) + }) + }) + + 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('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 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('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) + }) + }) +}) + +describe('parsePackageSpec', () => { it('parses unscoped package with exact version', () => { - expect(parsePackageSpecifier('esbuild@0.25.12')).toEqual({ + expect(parsePackageSpec('esbuild@0.25.12')).toEqual({ name: 'esbuild', version: '0.25.12', }) }) it('parses unscoped package with caret range', () => { - expect(parsePackageSpecifier('react@^18.0.0')).toEqual({ + expect(parsePackageSpec('react@^18.0.0')).toEqual({ name: 'react', version: '^18.0.0', }) }) it('parses scoped package with exact version', () => { - expect(parsePackageSpecifier('@angular/core@18.0.0')).toEqual({ + expect(parsePackageSpec('@angular/core@18.0.0')).toEqual({ name: '@angular/core', version: '18.0.0', }) }) it('parses scoped package with range', () => { - expect(parsePackageSpecifier('@angular/core@^18')).toEqual({ + expect(parsePackageSpec('@angular/core@^18')).toEqual({ name: '@angular/core', version: '^18', }) }) it('returns name only for unscoped package without version', () => { - expect(parsePackageSpecifier('esbuild')).toEqual({ name: 'esbuild' }) + expect(parsePackageSpec('esbuild')).toEqual({ name: 'esbuild' }) }) it('returns name only for scoped package without version', () => { - expect(parsePackageSpecifier('@angular/core')).toEqual({ name: '@angular/core' }) + expect(parsePackageSpec('@angular/core')).toEqual({ name: '@angular/core' }) }) it('returns name only for bare scope', () => { - expect(parsePackageSpecifier('@angular')).toEqual({ name: '@angular' }) + expect(parsePackageSpec('@angular')).toEqual({ name: '@angular' }) }) it('handles trailing @ with no version', () => { - expect(parsePackageSpecifier('esbuild@')).toEqual({ name: 'esbuild@' }) + expect(parsePackageSpec('esbuild@')).toEqual({ name: 'esbuild', version: '' }) }) it('parses version with union range', () => { - expect(parsePackageSpecifier('@angular/core@^18 || ^19')).toEqual({ + expect(parsePackageSpec('@angular/core@^18 || ^19')).toEqual({ name: '@angular/core', version: '^18 || ^19', }) }) it('parses dist-tag as version', () => { - expect(parsePackageSpecifier('nuxt@latest')).toEqual({ + expect(parsePackageSpec('nuxt@latest')).toEqual({ name: 'nuxt', version: 'latest', }) From 41853bffd3c0066f382d02ae65c9b180f5e8510b Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:56:23 +0100 Subject: [PATCH 07/11] fix: apply semver query filter on load --- app/components/Package/Versions.vue | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/components/Package/Versions.vue b/app/components/Package/Versions.vue index 51ab005112..37c37d9cdb 100644 --- a/app/components/Package/Versions.vue +++ b/app/components/Package/Versions.vue @@ -52,7 +52,11 @@ const effectiveCurrentVersion = computed( const semverFilter = ref((typeof route.query.semver === 'string' ? route.query.semver : '') || '') // Load all versions when a valid semver filter is entered -watch(semverFilter, async newFilter => { +watch(semverFilter, loadVersionsForFilter) +// A filter from ?semver= is set before mount, so the watcher never sees it change +onMounted(() => loadVersionsForFilter(semverFilter.value)) + +async function loadVersionsForFilter(newFilter: string) { const trimmed = newFilter.trim() if (trimmed === '' || hasLoadedAll.value) return if (!normalizeRange(trimmed)) return @@ -65,7 +69,7 @@ watch(semverFilter, async newFilter => { } catch { // Silently fail — user can still use the filter with already-known versions } -}) +} // Collect all known versions: initial props + dynamically loaded ones const allKnownVersions = computed(() => { const versions = new Set(Object.keys(props.versions)) From 20b68b747f5d65c2b8390526d8ba1b66f9db87b7 Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:51:48 +0100 Subject: [PATCH 08/11] fix: navigate pkg@version searches without racing the url update --- app/composables/useGlobalSearch.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/composables/useGlobalSearch.ts b/app/composables/useGlobalSearch.ts index eecd177d17..b3ea00e306 100644 --- a/app/composables/useGlobalSearch.ts +++ b/app/composables/useGlobalSearch.ts @@ -1,3 +1,4 @@ +import { parsePackageSpec } from '#shared/utils/parse-package-param' import { normalizeSearchParam } from '#shared/utils/url' import { debounce } from 'perfect-debounce' @@ -77,6 +78,16 @@ export function useGlobalSearch(place: 'header' | 'content' = 'content') { function flushUpdateUrlQuery() { // Commit the current query when explicitly submitted (Enter pressed) commitSearchQuery.cancel() + + // "pkg@version" goes straight to the package page. Drop the pending debounced + // `router.replace(?q=…)` first: landing after the push, it would cancel it. + const { name, version } = parsePackageSpec(searchQuery.value.trim()) + if (name && version) { + updateUrlQuery.cancel() + router.push(packageRoute(name, version)) + return + } + committedSearchQuery.value = searchQuery.value // When instant search is off the debounce queue is empty, so call directly if (!settings.value.instantSearch) { From cd71a6a64ad7340feacebdcfd4da16415eb16bc9 Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:40:00 +0100 Subject: [PATCH 09/11] test: drop fetch mocks restored by a merge --- modules/runtime/server/cache.ts | 50 --------------------------------- 1 file changed, 50 deletions(-) diff --git a/modules/runtime/server/cache.ts b/modules/runtime/server/cache.ts index 086c44823c..5359f75ea4 100644 --- a/modules/runtime/server/cache.ts +++ b/modules/runtime/server/cache.ts @@ -102,56 +102,6 @@ function getMockForUrl(url: string): MockResult | null { } } - // npms.io API - return mock package score data - if (host === 'api.npms.io') { - const packageMatch = decodeURIComponent(pathname).match(/^\/v2\/package\/(.+)$/) - if (packageMatch?.[1]) { - return { - data: { - analyzedAt: new Date().toISOString(), - collected: { - metadata: { name: packageMatch[1] }, - }, - score: { - final: 0.75, - detail: { - quality: 0.8, - popularity: 0.7, - maintenance: 0.75, - }, - }, - }, - } - } - } - - // jsdelivr CDN - return 404 for README files, etc. - if (host === 'cdn.jsdelivr.net') { - // Return null data which will cause a 404 - README files are optional - return { data: null } - } - - // jsdelivr data API - return mock file listing - if (host === 'data.jsdelivr.com') { - const packageMatch = decodeURIComponent(pathname).match(/^\/v1\/packages\/npm\/(.+)$/) - if (packageMatch?.[1]) { - const pkgWithVersion = packageMatch[1] - const parsed = parsePackageSpec(pkgWithVersion) - return { - data: { - type: 'npm', - name: parsed.name, - version: parsed.version || 'latest', - files: [ - { name: 'package.json', hash: 'abc123', size: 1000 }, - { name: 'index.js', hash: 'def456', size: 500 }, - { name: 'README.md', hash: 'ghi789', size: 2000 }, - ], - }, - } - } - } - // Gravatar API - return 404 (avatars not needed in tests) if (host === 'www.gravatar.com') { return { data: null } From 4b212705917a7e0eba4645f0c9336f958173fc7c Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:40:01 +0100 Subject: [PATCH 10/11] fix: route dist-tags to the version page --- app/utils/router.ts | 10 +++++++--- docs/content/2.guide/1.features.md | 4 ++-- docs/content/2.guide/3.url-structure.md | 9 ++++----- test/e2e/search-at-version.spec.ts | 8 ++------ test/unit/app/utils/router.spec.ts | 16 +++++++++++----- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/utils/router.ts b/app/utils/router.ts index 0be4429f3c..c937e91cb3 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -1,5 +1,5 @@ import type { RouteLocationRaw } from 'vue-router' -import { isValid as isValidSingleVersion } from 'verkit' +import { isValid as isValidSingleVersion, normalizeRange } from 'verkit' import { splitPackageName } from '~/utils/package-name' export function packageRoute( @@ -10,14 +10,18 @@ export function packageRoute( const { org, name } = splitPackageName(packageName) if (version) { - if (isValidSingleVersion(version)) { + // Exact versions and dist-tags (e.g. `latest`) open the version page, which resolves tags. + // The semver filter below rejects tags, as they aren't ranges. + if (isValidSingleVersion(version) || !normalizeRange(version)) { return { name: 'package-version', params: { org, name, - version, + // remove spaces to be correctly resolved by router + version: version.replace(/\s+/g, ''), }, + hash, } } diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index 738afebfab..e731c9cafd 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -22,9 +22,9 @@ Type `package@version` in the search bar and press `Enter` to go directly to tha | `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 | +| `nuxt@latest` | Version page for the `latest` dist-tag | -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. +When the version is an exact version number (e.g. `3.4.0`), you'll land on that version's page, and a dist-tag (e.g. `latest`) opens the version it points to. 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 diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md index 2982f4671f..90bb19e1f8 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -40,11 +40,10 @@ npmx.dev also supports shorter, cleaner URLs: Append `?semver=#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 | +| 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` | 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. diff --git a/test/e2e/search-at-version.spec.ts b/test/e2e/search-at-version.spec.ts index 3c285c09c1..c784c78084 100644 --- a/test/e2e/search-at-version.spec.ts +++ b/test/e2e/search-at-version.spec.ts @@ -46,18 +46,14 @@ test.describe('Search pkg@version navigation', () => { await expect(page).toHaveURL(/#versions/) }) - test('nuxt@latest → navigates to package page with semver filter for dist-tag', async ({ - page, - goto, - }) => { + test('nuxt@latest → navigates to version page 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/) + await expect(page).toHaveURL(/\/package\/nuxt\/v\/latest/) }) test('plain package name without @ version → does not trigger version navigation', async ({ diff --git a/test/unit/app/utils/router.spec.ts b/test/unit/app/utils/router.spec.ts index 8896cba757..4feace6a0c 100644 --- a/test/unit/app/utils/router.spec.ts +++ b/test/unit/app/utils/router.spec.ts @@ -87,12 +87,18 @@ describe('packageRoute', () => { }) }) - it('returns package route with semver query for dist-tag', () => { + it('returns package version route for dist-tag', () => { expect(packageRoute('nuxt', 'latest')).toEqual({ - name: 'package', - params: { org: '', name: 'nuxt' }, - query: { semver: 'latest' }, - hash: '#versions', + name: 'package-version', + params: { org: '', name: 'nuxt', version: 'latest' }, + }) + }) + + it('keeps the hash on exact version routes', () => { + expect(packageRoute('react', '18.0.0', '#provenance')).toEqual({ + name: 'package-version', + params: { org: '', name: 'react', version: '18.0.0' }, + hash: '#provenance', }) }) }) From 4506c6329713956ffdbae528ee15405b985660c7 Mon Sep 17 00:00:00 2001 From: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:40:03 +0100 Subject: [PATCH 11/11] fix(ui): sync version filter with semver query --- app/components/Package/Versions.vue | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/components/Package/Versions.vue b/app/components/Package/Versions.vue index 37c37d9cdb..89e4de58b9 100644 --- a/app/components/Package/Versions.vue +++ b/app/components/Package/Versions.vue @@ -51,6 +51,14 @@ const effectiveCurrentVersion = computed( // Semver range filter (initialized from ?semver= query param if present) const semverFilter = ref((typeof route.query.semver === 'string' ? route.query.semver : '') || '') +// Navigating to another ?semver= on the same page (e.g. searching `pkg@^2`) reuses this component +watch( + () => route.query.semver, + semver => { + if (typeof semver === 'string') semverFilter.value = semver + }, +) + // Load all versions when a valid semver filter is entered watch(semverFilter, loadVersionsForFilter) // A filter from ?semver= is set before mount, so the watcher never sees it change