Skip to content

Commit e6c0d1d

Browse files
committed
feat: add input to filter package versions by semver
Add a text input to the versions section that filters displayed versions by any valid npm semver range (e.g. `^3.0.0`, `>=2.0.0 <3.0.0`, `~1.5.0`). It filters tag rows, expanded child versions, and "other versions" groups as-you-type. It shows an indication when the input is not a valid range, and a "no matches" message when nothing matches. It has a tooltip linking to a new docs page explaining npm semver ranges
1 parent a5b2e9c commit e6c0d1d

File tree

14 files changed

+523
-22
lines changed

14 files changed

+523
-22
lines changed

app/components/AppFooter.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import { NPMX_DOCS_SITE } from '#shared/utils/constants'
3+
24
const route = useRoute()
35
const isHome = computed(() => route.name === 'index')
46
@@ -13,7 +15,9 @@ const showModal = () => modalRef.value?.showModal?.()
1315
class="flex flex-col sm:flex-row sm:flex-wrap items-center sm:items-baseline justify-between gap-2 sm:gap-4"
1416
>
1517
<div>
16-
<p class="font-mono text-balance m-0 hidden sm:block">{{ $t('tagline') }}</p>
18+
<p class="font-mono text-balance m-0 hidden sm:block">
19+
{{ $t('tagline') }}
20+
</p>
1721
</div>
1822
<!-- Desktop: Show all links. Mobile: Links are in MobileMenu -->
1923
<div class="hidden sm:flex items-center gap-6 min-h-11 text-xs">
@@ -92,7 +96,7 @@ const showModal = () => modalRef.value?.showModal?.()
9296
</li>
9397
</ul>
9498
</Modal>
95-
<LinkBase to="https://docs.npmx.dev">
99+
<LinkBase :to="NPMX_DOCS_SITE">
96100
{{ $t('footer.docs') }}
97101
</LinkBase>
98102
<LinkBase to="https://repo.npmx.dev">

app/components/AppHeader.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { LinkBase } from '#components'
33
import type { NavigationConfig, NavigationConfigWithGroups } from '~/types'
44
import { isEditableElement } from '~/utils/input'
5+
import { NPMX_DOCS_SITE } from '#shared/utils/constants'
56
67
withDefaults(
78
defineProps<{
@@ -85,7 +86,7 @@ const mobileLinks = computed<NavigationConfigWithGroups>(() => [
8586
{
8687
name: 'Docs',
8788
label: $t('footer.docs'),
88-
href: 'https://docs.npmx.dev',
89+
href: NPMX_DOCS_SITE,
8990
target: '_blank',
9091
type: 'link',
9192
external: true,

app/components/Package/Versions.vue

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
<script setup lang="ts">
22
import type { PackageVersionInfo, SlimVersion } from '#shared/types'
3-
import { compare } from 'semver'
3+
import { compare, validRange } from 'semver'
44
import type { RouteLocationRaw } from 'vue-router'
55
import { fetchAllPackageVersions } from '~/utils/npm/api'
6+
import { NPMX_DOCS_SITE } from '#shared/utils/constants'
67
import {
78
buildVersionToTagsMap,
89
filterExcludedTags,
10+
filterVersions,
911
getPrereleaseChannel,
1012
getVersionGroupKey,
1113
getVersionGroupLabel,
@@ -83,6 +85,31 @@ const effectiveCurrentVersion = computed(
8385
() => props.selectedVersion ?? props.distTags.latest ?? undefined,
8486
)
8587
88+
// Semver range filter
89+
const semverFilter = ref('')
90+
// Collect all known versions: initial props + dynamically loaded ones
91+
const allKnownVersions = computed(() => {
92+
const versions = new Set(Object.keys(props.versions))
93+
for (const versionList of tagVersions.value.values()) {
94+
for (const v of versionList) {
95+
versions.add(v.version)
96+
}
97+
}
98+
for (const group of otherMajorGroups.value) {
99+
for (const v of group.versions) {
100+
versions.add(v.version)
101+
}
102+
}
103+
return [...versions]
104+
})
105+
const filteredVersionSet = computed(() =>
106+
filterVersions(allKnownVersions.value, semverFilter.value),
107+
)
108+
const isFilterActive = computed(() => semverFilter.value.trim() !== '')
109+
const isInvalidRange = computed(
110+
() => isFilterActive.value && validRange(semverFilter.value.trim()) === null,
111+
)
112+
86113
// All tag rows derived from props (SSR-safe)
87114
// Deduplicates so each version appears only once, with all its tags
88115
const allTagRows = computed(() => {
@@ -135,10 +162,16 @@ const isPackageDeprecated = computed(() => {
135162
136163
// Visible tag rows: limited to MAX_VISIBLE_TAGS
137164
// If package is NOT deprecated, filter out deprecated tags from visible list
165+
// When semver filter is active, also filter by matching version
138166
const visibleTagRows = computed(() => {
139-
const rows = isPackageDeprecated.value
167+
const rowsMaybeFilteredForDeprecation = isPackageDeprecated.value
140168
? allTagRows.value
141169
: allTagRows.value.filter(row => !row.primaryVersion.deprecated)
170+
const rows = isFilterActive.value
171+
? rowsMaybeFilteredForDeprecation.filter(row =>
172+
filteredVersionSet.value.has(row.primaryVersion.version),
173+
)
174+
: rowsMaybeFilteredForDeprecation
142175
const first = rows.slice(0, MAX_VISIBLE_TAGS)
143176
const latestTagRow = rows.find(row => row.tag === 'latest')
144177
// Ensure 'latest' tag is always included (at the end) if not already present
@@ -150,9 +183,14 @@ const visibleTagRows = computed(() => {
150183
})
151184
152185
// Hidden tag rows (all other tags) - shown in "Other versions"
153-
const hiddenTagRows = computed(() =>
154-
allTagRows.value.filter(row => !visibleTagRows.value.includes(row)),
155-
)
186+
// When semver filter is active, also filter by matching version
187+
const hiddenTagRows = computed(() => {
188+
const hiddenRows = allTagRows.value.filter(row => !visibleTagRows.value.includes(row))
189+
const rows = isFilterActive.value
190+
? hiddenRows.filter(row => filteredVersionSet.value.has(row.primaryVersion.version))
191+
: hiddenRows
192+
return rows
193+
})
156194
157195
// Client-side state for expansion and loaded versions
158196
const expandedTags = ref<Set<string>>(new Set())
@@ -166,6 +204,27 @@ const otherMajorGroups = shallowRef<
166204
>([])
167205
const otherVersionsLoading = shallowRef(false)
168206
207+
// Filtered major groups (applies semver filter when active)
208+
const filteredOtherMajorGroups = computed(() => {
209+
if (!isFilterActive.value) return otherMajorGroups.value
210+
return otherMajorGroups.value
211+
.map(group => ({
212+
...group,
213+
versions: group.versions.filter(v => filteredVersionSet.value.has(v.version)),
214+
}))
215+
.filter(group => group.versions.length > 0)
216+
})
217+
218+
// Whether the filter is active but nothing matches anywhere
219+
const hasNoFilterMatches = computed(() => {
220+
if (!isFilterActive.value) return false
221+
return (
222+
visibleTagRows.value.length === 0 &&
223+
hiddenTagRows.value.length === 0 &&
224+
filteredOtherMajorGroups.value.length === 0
225+
)
226+
})
227+
169228
// Cached full version list (local to component instance)
170229
const allVersionsCache = shallowRef<PackageVersionInfo[] | null>(null)
171230
const loadingVersions = shallowRef(false)
@@ -340,6 +399,13 @@ function getTagVersions(tag: string): VersionDisplay[] {
340399
return tagVersions.value.get(tag) ?? []
341400
}
342401
402+
// Get filtered versions for a tag (applies semver filter when active)
403+
function getFilteredTagVersions(tag: string): VersionDisplay[] {
404+
const versions = getTagVersions(tag)
405+
if (!isFilterActive.value) return versions
406+
return versions.filter(v => filteredVersionSet.value.has(v.version))
407+
}
408+
343409
function findClaimingTag(version: string): string | null {
344410
const versionChannel = getPrereleaseChannel(version)
345411
@@ -418,6 +484,61 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
418484
</ButtonBase>
419485
</template>
420486
<div class="space-y-0.5 min-w-0">
487+
<!-- Semver range filter -->
488+
<div class="px-1 pb-1">
489+
<div class="flex items-center gap-1.5">
490+
<InputBase
491+
v-model="semverFilter"
492+
type="text"
493+
:placeholder="$t('package.versions.filter_placeholder')"
494+
:aria-label="$t('package.versions.filter_placeholder')"
495+
:aria-invalid="isInvalidRange ? 'true' : undefined"
496+
:aria-describedby="isInvalidRange ? 'semver-filter-error' : undefined"
497+
autocomplete="off"
498+
class="flex-1 min-w-0"
499+
:class="isInvalidRange ? '!border-red-500' : ''"
500+
size="small"
501+
/>
502+
<TooltipApp interactive position="top">
503+
<span
504+
tabindex="0"
505+
class="i-carbon:information w-3.5 h-3.5 text-fg-subtle cursor-help shrink-0 rounded-sm"
506+
role="img"
507+
:aria-label="$t('package.versions.filter_help')"
508+
/>
509+
<template #content>
510+
<p class="text-xs text-fg-muted">
511+
<i18n-t keypath="package.versions.filter_tooltip" tag="span">
512+
<template #link>
513+
<LinkBase :to="`${NPMX_DOCS_SITE}/guide/semver-ranges`">{{
514+
$t('package.versions.filter_tooltip_link')
515+
}}</LinkBase>
516+
</template>
517+
</i18n-t>
518+
</p>
519+
</template>
520+
</TooltipApp>
521+
</div>
522+
<p
523+
v-if="isInvalidRange"
524+
id="semver-filter-error"
525+
class="text-red-500 text-3xs mt-1"
526+
role="alert"
527+
>
528+
{{ $t('package.versions.filter_invalid') }}
529+
</p>
530+
</div>
531+
532+
<!-- No matches message -->
533+
<div
534+
v-if="hasNoFilterMatches"
535+
class="px-1 py-2 text-xs text-fg-subtle"
536+
role="status"
537+
aria-live="polite"
538+
>
539+
{{ $t('package.versions.no_matches') }}
540+
</div>
541+
421542
<!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) -->
422543
<div v-for="row in visibleTagRows" :key="row.id">
423544
<div
@@ -512,11 +633,11 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
512633

513634
<!-- Expanded versions -->
514635
<div
515-
v-if="expandedTags.has(row.tag) && getTagVersions(row.tag).length > 1"
636+
v-if="expandedTags.has(row.tag) && getFilteredTagVersions(row.tag).length > 1"
516637
class="ms-4 ps-2 border-is border-border space-y-0.5 pe-2"
517638
>
518639
<div
519-
v-for="v in getTagVersions(row.tag).slice(1)"
640+
v-for="v in getFilteredTagVersions(row.tag).slice(1)"
520641
:key="v.version"
521642
class="py-1"
522643
:class="v.version === effectiveCurrentVersion ? 'rounded bg-bg-subtle px-2 -mx-2' : ''"
@@ -533,7 +654,9 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
533654
"
534655
:title="
535656
v.deprecated
536-
? $t('package.versions.deprecated_title', { version: v.version })
657+
? $t('package.versions.deprecated_title', {
658+
version: v.version,
659+
})
537660
: v.version
538661
"
539662
:classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined"
@@ -676,8 +799,8 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
676799
</div>
677800

678801
<!-- Version groups (untagged versions) -->
679-
<template v-if="otherMajorGroups.length > 0">
680-
<div v-for="group in otherMajorGroups" :key="group.groupKey">
802+
<template v-if="filteredOtherMajorGroups.length > 0">
803+
<div v-for="group in filteredOtherMajorGroups" :key="group.groupKey">
681804
<!-- Version group header -->
682805
<div
683806
v-if="group.versions.length > 1"
@@ -692,8 +815,12 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
692815
:aria-expanded="expandedMajorGroups.has(group.groupKey)"
693816
:aria-label="
694817
expandedMajorGroups.has(group.groupKey)
695-
? $t('package.versions.collapse_major', { major: group.label })
696-
: $t('package.versions.expand_major', { major: group.label })
818+
? $t('package.versions.collapse_major', {
819+
major: group.label,
820+
})
821+
: $t('package.versions.expand_major', {
822+
major: group.label,
823+
})
697824
"
698825
data-testid="major-group-expand-button"
699826
@click="toggleMajorGroup(group.groupKey)"
@@ -852,7 +979,9 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
852979
"
853980
:title="
854981
v.deprecated
855-
? $t('package.versions.deprecated_title', { version: v.version })
982+
? $t('package.versions.deprecated_title', {
983+
version: v.version,
984+
})
856985
: v.version
857986
"
858987
:classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined"

app/utils/versions.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { compare, valid } from 'semver'
1+
import { compare, satisfies, validRange, valid } from 'semver'
22

33
/**
44
* Utilities for handling npm package versions and dist-tags
@@ -179,3 +179,31 @@ export function getVersionGroupLabel(groupKey: string): string {
179179
export function isSameVersionGroup(versionA: string, versionB: string): boolean {
180180
return getVersionGroupKey(versionA) === getVersionGroupKey(versionB)
181181
}
182+
183+
/**
184+
* Filter versions by a semver range string.
185+
*
186+
* @param versions - Array of version strings to filter
187+
* @param range - A semver range string (e.g., "^3.0.0", ">=2.0.0 <3.0.0")
188+
* @returns Set of version strings that satisfy the range.
189+
* Returns all versions if range is empty/whitespace.
190+
* Returns empty set if range is invalid.
191+
*/
192+
export function filterVersions(versions: string[], range: string): Set<string> {
193+
const trimmed = range.trim()
194+
if (trimmed === '') {
195+
return new Set(versions)
196+
}
197+
198+
if (!validRange(trimmed)) {
199+
return new Set()
200+
}
201+
202+
const matched = new Set<string>()
203+
for (const v of versions) {
204+
if (satisfies(v, trimmed, { includePrerelease: true })) {
205+
matched.add(v)
206+
}
207+
}
208+
return matched
209+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
title: Semver Ranges
3+
description: Learn how to use semver ranges to filter package versions on npmx.dev
4+
navigation:
5+
icon: i-lucide-filter
6+
---
7+
8+
npm uses [semantic versioning](https://semver.org/) (semver) to manage package versions. A **semver range** is a string that describes a set of version numbers. On npmx, you can type a semver range into the version filter input on any package page to quickly find matching versions.
9+
10+
## Version format
11+
12+
Every npm version follows the format **MAJOR.MINOR.PATCH**, for example `3.2.1`:
13+
14+
- **MAJOR** - incremented for breaking changes
15+
- **MINOR** - incremented for new features (backwards-compatible)
16+
- **PATCH** - incremented for bug fixes (backwards-compatible)
17+
18+
Some versions also include a **prerelease** tag, such as `4.0.0-beta.1`.
19+
20+
## Common range syntax
21+
22+
| Range | Meaning | Example matches |
23+
| ---------------- | ------------------------------------------------- | -------------------- |
24+
| `*` | Any version | 0.0.2, 3.1.0, 3.2.6 |
25+
| `^3.0.0` | Compatible with 3.x (same major) | 3.0.0, 3.1.0, 3.9.5 |
26+
| `~3.2.0` | At least 3.2.0, same major.minor | 3.2.0, 3.2.1, 3.2.99 |
27+
| `3.2.x` | At least 3.2.0, same major.minor | 3.2.0, 3.2.1, 3.2.99 |
28+
| `>=2.0.0 <3.0.0` | At least 2.0.0 but below 3.0.0 | 2.0.0, 2.5.3, 2.99.0 |
29+
| `1.2.3` | Exactly this version | 1.2.3 |
30+
| `=1.2.3` | Exactly this version | 1.2.3 |
31+
| `^0.3.1` | At least 0.3.1, same major.minor (0.x is special) | 0.3.1, 0.3.2 |
32+
| `^0.0.4` | Exactly 0.0.4 (0.0.x is special) | 0.0.4 (only) |
33+
34+
## Examples
35+
36+
### Find all 3.x versions
37+
38+
Type `^3.0.0` to see every version compatible with major version 3.
39+
40+
### Find patch releases for a specific minor
41+
42+
Type `~2.4.0` to see only 2.4.x patch releases (2.4.0, 2.4.1, 2.4.2, etc.).
43+
44+
### Find versions in a specific range
45+
46+
Type `>=1.0.0 <2.0.0` to see all 1.x stable releases.
47+
48+
### Find a specific version
49+
50+
Type the exact version number, like `5.3.1`, to check if it exists.
51+
52+
### Find prerelease versions
53+
54+
Type `>=3.0.0-alpha.0` to find alpha, beta, and release candidate versions for a major release.
55+
56+
## Learn more
57+
58+
The full semver range specification is documented at [node-semver](https://github.com/npm/node-semver#ranges).

0 commit comments

Comments
 (0)