Skip to content
Merged
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
8 changes: 6 additions & 2 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { NPMX_DOCS_SITE } from '#shared/utils/constants'

const route = useRoute()
const isHome = computed(() => route.name === 'index')

Expand All @@ -13,7 +15,9 @@ const showModal = () => modalRef.value?.showModal?.()
class="flex flex-col sm:flex-row sm:flex-wrap items-center sm:items-baseline justify-between gap-2 sm:gap-4"
>
<div>
<p class="font-mono text-balance m-0 hidden sm:block">{{ $t('tagline') }}</p>
<p class="font-mono text-balance m-0 hidden sm:block">
{{ $t('tagline') }}
</p>
</div>
<!-- Desktop: Show all links. Mobile: Links are in MobileMenu -->
<div class="hidden sm:flex items-center gap-6 min-h-11 text-xs">
Expand Down Expand Up @@ -92,7 +96,7 @@ const showModal = () => modalRef.value?.showModal?.()
</li>
</ul>
</Modal>
<LinkBase to="https://docs.npmx.dev">
<LinkBase :to="NPMX_DOCS_SITE">
{{ $t('footer.docs') }}
</LinkBase>
<LinkBase to="https://repo.npmx.dev">
Expand Down
3 changes: 2 additions & 1 deletion app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { LinkBase } from '#components'
import type { NavigationConfig, NavigationConfigWithGroups } from '~/types'
import { isEditableElement } from '~/utils/input'
import { NPMX_DOCS_SITE } from '#shared/utils/constants'

withDefaults(
defineProps<{
Expand Down Expand Up @@ -85,7 +86,7 @@ const mobileLinks = computed<NavigationConfigWithGroups>(() => [
{
name: 'Docs',
label: $t('footer.docs'),
href: 'https://docs.npmx.dev',
href: NPMX_DOCS_SITE,
target: '_blank',
type: 'link',
external: true,
Expand Down
155 changes: 142 additions & 13 deletions app/components/Package/Versions.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<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 { NPMX_DOCS_SITE } from '#shared/utils/constants'
import {
buildVersionToTagsMap,
filterExcludedTags,
filterVersions,
getPrereleaseChannel,
getVersionGroupKey,
getVersionGroupLabel,
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
)
Comment on lines +218 to +225
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid showing “No versions match” when the range is invalid.

Right now an invalid range triggers both the error message and the no‑matches banner, which is confusing. Gate the banner on a valid range (and, optionally, after full load).

Suggested fix
 const hasNoFilterMatches = computed(() => {
   if (!isFilterActive.value) return false
+  if (isInvalidRange.value) return false
   return (
     visibleTagRows.value.length === 0 &&
     hiddenTagRows.value.length === 0 &&
     filteredOtherMajorGroups.value.length === 0
   )
 })

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it looks fine, but open to thoughts

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@serhalp, totally understood! The double message could actually help some users understand that the filter just isn't working yet—I can see the logic there. Thanks for considering it! 🙂

/\_/\

( o.o )
> ^

})

// 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="`${NPMX_DOCS_SITE}/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).
Loading
Loading