Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 142 additions & 13 deletions app/components/Package/Versions.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup lang="ts">
import type { PackageVersionInfo, SlimVersion } from '#shared/types'
import { compare } from 'semver'
import { compare, validRange } from 'semver'
import type { RouteLocationRaw } from 'vue-router'
import { fetchAllPackageVersions } from '~/utils/npm/api'
import {
buildVersionToTagsMap,
filterExcludedTags,
filterVersions,
getPrereleaseChannel,
getVersionGroupKey,
getVersionGroupLabel,
Expand All @@ -20,6 +21,7 @@ const props = defineProps<{
selectedVersion?: string
}>()

const { docsUrl } = useAppUrls()
const chartModal = useModal('chart-modal')
const hasDistributionModalTransitioned = shallowRef(false)
const isDistributionModalOpen = shallowRef(false)
Expand Down Expand Up @@ -83,6 +85,31 @@ const effectiveCurrentVersion = computed(
() => props.selectedVersion ?? props.distTags.latest ?? undefined,
)

// Semver range filter
const semverFilter = ref('')
// Collect all known versions: initial props + dynamically loaded ones
const allKnownVersions = computed(() => {
const versions = new Set(Object.keys(props.versions))
for (const versionList of tagVersions.value.values()) {
for (const v of versionList) {
versions.add(v.version)
}
}
for (const group of otherMajorGroups.value) {
for (const v of group.versions) {
versions.add(v.version)
}
}
return [...versions]
})
const filteredVersionSet = computed(() =>
filterVersions(allKnownVersions.value, semverFilter.value),
)
const isFilterActive = computed(() => semverFilter.value.trim() !== '')
const isInvalidRange = computed(
() => isFilterActive.value && validRange(semverFilter.value.trim()) === null,
)

// All tag rows derived from props (SSR-safe)
// Deduplicates so each version appears only once, with all its tags
const allTagRows = computed(() => {
Expand Down Expand Up @@ -135,10 +162,16 @@ const isPackageDeprecated = computed(() => {

// Visible tag rows: limited to MAX_VISIBLE_TAGS
// If package is NOT deprecated, filter out deprecated tags from visible list
// When semver filter is active, also filter by matching version
const visibleTagRows = computed(() => {
const rows = isPackageDeprecated.value
const rowsMaybeFilteredForDeprecation = isPackageDeprecated.value
? allTagRows.value
: allTagRows.value.filter(row => !row.primaryVersion.deprecated)
const rows = isFilterActive.value
? rowsMaybeFilteredForDeprecation.filter(row =>
filteredVersionSet.value.has(row.primaryVersion.version),
)
: rowsMaybeFilteredForDeprecation
const first = rows.slice(0, MAX_VISIBLE_TAGS)
const latestTagRow = rows.find(row => row.tag === 'latest')
// Ensure 'latest' tag is always included (at the end) if not already present
Expand All @@ -150,9 +183,14 @@ const visibleTagRows = computed(() => {
})

// Hidden tag rows (all other tags) - shown in "Other versions"
const hiddenTagRows = computed(() =>
allTagRows.value.filter(row => !visibleTagRows.value.includes(row)),
)
// When semver filter is active, also filter by matching version
const hiddenTagRows = computed(() => {
const hiddenRows = allTagRows.value.filter(row => !visibleTagRows.value.includes(row))
const rows = isFilterActive.value
? hiddenRows.filter(row => filteredVersionSet.value.has(row.primaryVersion.version))
: hiddenRows
return rows
})

// Client-side state for expansion and loaded versions
const expandedTags = ref<Set<string>>(new Set())
Expand All @@ -166,6 +204,27 @@ const otherMajorGroups = shallowRef<
>([])
const otherVersionsLoading = shallowRef(false)

// Filtered major groups (applies semver filter when active)
const filteredOtherMajorGroups = computed(() => {
if (!isFilterActive.value) return otherMajorGroups.value
return otherMajorGroups.value
.map(group => ({
...group,
versions: group.versions.filter(v => filteredVersionSet.value.has(v.version)),
}))
.filter(group => group.versions.length > 0)
})

// Whether the filter is active but nothing matches anywhere
const hasNoFilterMatches = computed(() => {
if (!isFilterActive.value) return false
return (
visibleTagRows.value.length === 0 &&
hiddenTagRows.value.length === 0 &&
filteredOtherMajorGroups.value.length === 0
)
})

// Cached full version list (local to component instance)
const allVersionsCache = shallowRef<PackageVersionInfo[] | null>(null)
const loadingVersions = shallowRef(false)
Expand Down Expand Up @@ -340,6 +399,13 @@ function getTagVersions(tag: string): VersionDisplay[] {
return tagVersions.value.get(tag) ?? []
}

// Get filtered versions for a tag (applies semver filter when active)
function getFilteredTagVersions(tag: string): VersionDisplay[] {
const versions = getTagVersions(tag)
if (!isFilterActive.value) return versions
return versions.filter(v => filteredVersionSet.value.has(v.version))
}

function findClaimingTag(version: string): string | null {
const versionChannel = getPrereleaseChannel(version)

Expand Down Expand Up @@ -418,6 +484,61 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
</ButtonBase>
</template>
<div class="space-y-0.5 min-w-0">
<!-- Semver range filter -->
<div class="px-1 pb-1">
<div class="flex items-center gap-1.5">
<InputBase
v-model="semverFilter"
type="text"
:placeholder="$t('package.versions.filter_placeholder')"
:aria-label="$t('package.versions.filter_placeholder')"
:aria-invalid="isInvalidRange ? 'true' : undefined"
:aria-describedby="isInvalidRange ? 'semver-filter-error' : undefined"
autocomplete="off"
class="flex-1 min-w-0"
:class="isInvalidRange ? '!border-red-500' : ''"
size="small"
/>
<TooltipApp interactive position="top">
<span
tabindex="0"
class="i-carbon:information w-3.5 h-3.5 text-fg-subtle cursor-help shrink-0 rounded-sm"
role="img"
:aria-label="$t('package.versions.filter_help')"
/>
<template #content>
<p class="text-xs text-fg-muted">
<i18n-t keypath="package.versions.filter_tooltip" tag="span">
<template #link>
<LinkBase :to="`${docsUrl}/guide/semver-ranges`">{{
$t('package.versions.filter_tooltip_link')
}}</LinkBase>
</template>
</i18n-t>
</p>
</template>
</TooltipApp>
</div>
<p
v-if="isInvalidRange"
id="semver-filter-error"
class="text-red-500 text-3xs mt-1"
role="alert"
>
{{ $t('package.versions.filter_invalid') }}
</p>
</div>

<!-- No matches message -->
<div
v-if="hasNoFilterMatches"
class="px-1 py-2 text-xs text-fg-subtle"
role="status"
aria-live="polite"
>
{{ $t('package.versions.no_matches') }}
</div>

<!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) -->
<div v-for="row in visibleTagRows" :key="row.id">
<div
Expand Down Expand Up @@ -512,11 +633,11 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b

<!-- Expanded versions -->
<div
v-if="expandedTags.has(row.tag) && getTagVersions(row.tag).length > 1"
v-if="expandedTags.has(row.tag) && getFilteredTagVersions(row.tag).length > 1"
class="ms-4 ps-2 border-is border-border space-y-0.5 pe-2"
>
<div
v-for="v in getTagVersions(row.tag).slice(1)"
v-for="v in getFilteredTagVersions(row.tag).slice(1)"
:key="v.version"
class="py-1"
:class="v.version === effectiveCurrentVersion ? 'rounded bg-bg-subtle px-2 -mx-2' : ''"
Expand All @@ -533,7 +654,9 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
"
:title="
v.deprecated
? $t('package.versions.deprecated_title', { version: v.version })
? $t('package.versions.deprecated_title', {
version: v.version,
})
: v.version
"
:classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined"
Expand Down Expand Up @@ -676,8 +799,8 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
</div>

<!-- Version groups (untagged versions) -->
<template v-if="otherMajorGroups.length > 0">
<div v-for="group in otherMajorGroups" :key="group.groupKey">
<template v-if="filteredOtherMajorGroups.length > 0">
<div v-for="group in filteredOtherMajorGroups" :key="group.groupKey">
<!-- Version group header -->
<div
v-if="group.versions.length > 1"
Expand All @@ -692,8 +815,12 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
:aria-expanded="expandedMajorGroups.has(group.groupKey)"
:aria-label="
expandedMajorGroups.has(group.groupKey)
? $t('package.versions.collapse_major', { major: group.label })
: $t('package.versions.expand_major', { major: group.label })
? $t('package.versions.collapse_major', {
major: group.label,
})
: $t('package.versions.expand_major', {
major: group.label,
})
"
data-testid="major-group-expand-button"
@click="toggleMajorGroup(group.groupKey)"
Expand Down Expand Up @@ -852,7 +979,9 @@ function majorGroupContainsCurrent(group: (typeof otherMajorGroups.value)[0]): b
"
:title="
v.deprecated
? $t('package.versions.deprecated_title', { version: v.version })
? $t('package.versions.deprecated_title', {
version: v.version,
})
: v.version
"
:classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined"
Expand Down
30 changes: 29 additions & 1 deletion app/utils/versions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { compare, valid } from 'semver'
import { compare, satisfies, validRange, valid } from 'semver'

/**
* Utilities for handling npm package versions and dist-tags
Expand Down Expand Up @@ -179,3 +179,31 @@ export function getVersionGroupLabel(groupKey: string): string {
export function isSameVersionGroup(versionA: string, versionB: string): boolean {
return getVersionGroupKey(versionA) === getVersionGroupKey(versionB)
}

/**
* Filter versions by a semver range string.
*
* @param versions - Array of version strings to filter
* @param range - A semver range string (e.g., "^3.0.0", ">=2.0.0 <3.0.0")
* @returns Set of version strings that satisfy the range.
* Returns all versions if range is empty/whitespace.
* Returns empty set if range is invalid.
*/
export function filterVersions(versions: string[], range: string): Set<string> {
const trimmed = range.trim()
if (trimmed === '') {
return new Set(versions)
}

if (!validRange(trimmed)) {
return new Set()
}

const matched = new Set<string>()
for (const v of versions) {
if (satisfies(v, trimmed, { includePrerelease: true })) {
matched.add(v)
}
}
return matched
}
58 changes: 58 additions & 0 deletions docs/content/2.guide/5.semver-ranges.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
title: Semver Ranges
description: Learn how to use semver ranges to filter package versions on npmx.dev
navigation:
icon: i-lucide-filter
---

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.

## Version format

Every npm version follows the format **MAJOR.MINOR.PATCH**, for example `3.2.1`:

- **MAJOR** - incremented for breaking changes
- **MINOR** - incremented for new features (backwards-compatible)
- **PATCH** - incremented for bug fixes (backwards-compatible)

Some versions also include a **prerelease** tag, such as `4.0.0-beta.1`.

## Common range syntax

| Range | Meaning | Example matches |
| ---------------- | ------------------------------------------------- | -------------------- |
| `*` | Any version | 0.0.2, 3.1.0, 3.2.6 |
| `^3.0.0` | Compatible with 3.x (same major) | 3.0.0, 3.1.0, 3.9.5 |
| `~3.2.0` | At least 3.2.0, same major.minor | 3.2.0, 3.2.1, 3.2.99 |
| `3.2.x` | At least 3.2.0, same major.minor | 3.2.0, 3.2.1, 3.2.99 |
| `>=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 |
| `1.2.3` | Exactly this version | 1.2.3 |
| `=1.2.3` | Exactly this version | 1.2.3 |
| `^0.3.1` | At least 0.3.1, same major.minor (0.x is special) | 0.3.1, 0.3.2 |
| `^0.0.4` | Exactly 0.0.4 (0.0.x is special) | 0.0.4 (only) |

## Examples

### Find all 3.x versions

Type `^3.0.0` to see every version compatible with major version 3.

### Find patch releases for a specific minor

Type `~2.4.0` to see only 2.4.x patch releases (2.4.0, 2.4.1, 2.4.2, etc.).

### Find versions in a specific range

Type `>=1.0.0 <2.0.0` to see all 1.x stable releases.

### Find a specific version

Type the exact version number, like `5.3.1`, to check if it exists.

### Find prerelease versions

Type `>=3.0.0-alpha.0` to find alpha, beta, and release candidate versions for a major release.

## Learn more

The full semver range specification is documented at [node-semver](https://github.com/npm/node-semver#ranges).
8 changes: 7 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,13 @@
"recent_versions_only_tooltip": "Show only versions published within the last year.",
"show_low_usage": "Show low usage versions",
"show_low_usage_tooltip": "Include version groups with less than 1% of total downloads.",
"date_range_tooltip": "Last week of version distributions only"
"date_range_tooltip": "Last week of version distributions only",
"filter_placeholder": "Filter by semver (e.g. ^3.0.0)",
"filter_invalid": "Invalid semver range",
"filter_help": "Semver range filter help",
"filter_tooltip": "Filter versions using a {link}. For example, ^3.0.0 shows all 3.x versions.",
"filter_tooltip_link": "semver range",
"no_matches": "No versions match this range"
},
"dependencies": {
"title": "Dependency ({count}) | Dependencies ({count})",
Expand Down
8 changes: 7 additions & 1 deletion i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,13 @@
"more_tagged": "{count} de plus avec tag",
"all_covered": "Toutes les versions sont couvertes par les tags ci-dessus",
"deprecated_title": "{version} (dépréciée)",
"view_all": "Voir la version | Voir les {count} versions"
"view_all": "Voir la version | Voir les {count} versions",
"filter_placeholder": "Filtrer par plage semver (ex. ^3.0.0)",
"filter_invalid": "Plage semver invalide",
"filter_help": "Infos sur le filtre de plage semver",
"filter_tooltip": "Filtrer les versions avec une {link}. Par exemple, ^3.0.0 affiche toutes les versions 3.x.",
"filter_tooltip_link": "plage semver",
"no_matches": "Aucune version ne correspond à cette plage"
},
"dependencies": {
"title": "Dépendances ({count})",
Expand Down
Loading