diff --git a/app/components/Package/Versions.vue b/app/components/Package/Versions.vue index 2635d051d9..89e4de58b9 100644 --- a/app/components/Package/Versions.vue +++ b/app/components/Package/Versions.vue @@ -48,11 +48,23 @@ 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 : '') || '') + +// 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, 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 +77,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)) 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) { diff --git a/app/pages/search.vue b/app/pages/search.vue index 9010734a57..b89dd57881 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 { parsePackageSpec } from '#shared/utils/parse-package-param' import { normalizeSearchParam } from '#shared/utils/url' definePageMeta({ @@ -499,6 +500,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 } = parsePackageSpec(inputValue) + if (version) { + return navigateTo(packageRoute(name, version)) + } + // When instantSearch is off, commit the query so search starts committedQuery.value = inputValue diff --git a/app/utils/router.ts b/app/utils/router.ts index 2ea9c0700b..c937e91cb3 100644 --- a/app/utils/router.ts +++ b/app/utils/router.ts @@ -1,4 +1,5 @@ import type { RouteLocationRaw } from 'vue-router' +import { isValid as isValidSingleVersion, normalizeRange } from 'verkit' import { splitPackageName } from '~/utils/package-name' export function packageRoute( @@ -9,15 +10,32 @@ export function packageRoute( const { org, name } = splitPackageName(packageName) if (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, + // remove spaces to be correctly resolved by router + version: version.replace(/\s+/g, ''), + }, + hash, + } + } + + // 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/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index 62b1cdac57..e731c9cafd 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` | 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, 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 ### Command palette diff --git a/docs/content/2.guide/2.keyboard-shortcuts.md b/docs/content/2.guide/2.keyboard-shortcuts.md index 1c35a31660..e16880246b 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 d7ebfdc614..90bb19e1f8 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -36,6 +36,17 @@ 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` | + +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. + ## Share exact feature URLs npmx.dev keeps package exploration views in routes or query parameters so you can copy and share the exact state you are looking at. 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). diff --git a/test/e2e/search-at-version.spec.ts b/test/e2e/search-at-version.spec.ts new file mode 100644 index 0000000000..c784c78084 --- /dev/null +++ b/test/e2e/search-at-version.spec.ts @@ -0,0 +1,77 @@ +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 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\/v\/latest/) + }) + + 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$/) + }) +}) diff --git a/test/unit/app/utils/router.spec.ts b/test/unit/app/utils/router.spec.ts index f7a944ab44..4feace6a0c 100644 --- a/test/unit/app/utils/router.spec.ts +++ b/test/unit/app/utils/router.spec.ts @@ -1,5 +1,133 @@ import { describe, expect, it } from 'vite-plus/test' -import { docsRoute } from '~/utils/router' +import { diffRoute, docsRoute, packageRoute } from '~/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 version route for dist-tag', () => { + expect(packageRoute('nuxt', 'latest')).toEqual({ + 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', + }) + }) + }) + + 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' }, + }) + }) +}) describe('docsRoute', () => { it('emits a scoped name as two path segments (literal slash, not %2F)', () => { diff --git a/test/unit/shared/utils/parse-package-param.spec.ts b/test/unit/shared/utils/parse-package-param.spec.ts index 9cb797dca8..ae2c07cb52 100644 --- a/test/unit/shared/utils/parse-package-param.spec.ts +++ b/test/unit/shared/utils/parse-package-param.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test' -import { parsePackageParam } from '#shared/utils/parse-package-param' +import { parsePackageParam, parsePackageSpec } from '#shared/utils/parse-package-param' describe('parsePackageParam', () => { describe('unscoped packages', () => { @@ -133,3 +133,74 @@ describe('parsePackageParam', () => { }) }) }) + +describe('parsePackageSpec', () => { + it('parses unscoped package with exact version', () => { + expect(parsePackageSpec('esbuild@0.25.12')).toEqual({ + name: 'esbuild', + version: '0.25.12', + }) + }) + + it('parses unscoped package with caret range', () => { + expect(parsePackageSpec('react@^18.0.0')).toEqual({ + name: 'react', + version: '^18.0.0', + }) + }) + + it('parses scoped package with exact version', () => { + expect(parsePackageSpec('@angular/core@18.0.0')).toEqual({ + name: '@angular/core', + version: '18.0.0', + }) + }) + + it('parses scoped package with range', () => { + expect(parsePackageSpec('@angular/core@^18')).toEqual({ + name: '@angular/core', + version: '^18', + }) + }) + + it('returns name only for unscoped package without version', () => { + expect(parsePackageSpec('esbuild')).toEqual({ name: 'esbuild' }) + }) + + it('returns name only for scoped package without version', () => { + expect(parsePackageSpec('@angular/core')).toEqual({ name: '@angular/core' }) + }) + + it('returns name only for bare scope', () => { + expect(parsePackageSpec('@angular')).toEqual({ name: '@angular' }) + }) + + it('handles trailing @ with no version', () => { + expect(parsePackageSpec('esbuild@')).toEqual({ name: 'esbuild', version: '' }) + }) + + it('parses version with union range', () => { + expect(parsePackageSpec('@angular/core@^18 || ^19')).toEqual({ + name: '@angular/core', + version: '^18 || ^19', + }) + }) + + it('parses dist-tag as version', () => { + expect(parsePackageSpec('nuxt@latest')).toEqual({ + name: 'nuxt', + version: 'latest', + }) + }) +}) + +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'], + }) + }) +})