forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { compare, satisfies, validRange, valid } from 'semver'
2
3/**
4 * Utilities for handling npm package versions and dist-tags
5 */
6
7/**
8 * Check if a version string is an exact semver version.
9 * Returns true for "1.2.3", "1.0.0-beta.1", etc.
10 * Returns false for ranges like "^1.2.3", ">=1.0.0", tags like "latest", etc.
11 * @param version - The version string to check
12 * @returns true if the version is an exact semver version
13 */
14export function isExactVersion(version: string): boolean {
15 return valid(version) !== null
16}
17
18/** Parsed semver version components */
19export interface ParsedVersion {
20 major: number
21 minor: number
22 patch: number
23 prerelease: string
24}
25
26/**
27 * Parse a semver version string into its components
28 * @param version - The version string (e.g., "1.2.3" or "1.0.0-beta.1")
29 * @returns Parsed version object with major, minor, patch, and prerelease
30 */
31export function parseVersion(version: string): ParsedVersion {
32 const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/)
33 if (!match) return { major: 0, minor: 0, patch: 0, prerelease: '' }
34 return {
35 major: Number(match[1]),
36 minor: Number(match[2]),
37 patch: Number(match[3]),
38 prerelease: match[4] ?? '',
39 }
40}
41
42/**
43 * Extract the prerelease channel from a version string
44 * @param version - The version string (e.g., "1.0.0-beta.1")
45 * @returns The channel name (e.g., "beta") or empty string for stable versions
46 */
47export function getPrereleaseChannel(version: string): string {
48 const parsed = parseVersion(version)
49 if (!parsed.prerelease) return ''
50 const match = parsed.prerelease.match(/^([a-z]+)/i)
51 return match ? match[1]!.toLowerCase() : ''
52}
53
54/**
55 * Sort tags with 'latest' first, then alphabetically
56 * @param tags - Array of tag names
57 * @returns New sorted array
58 */
59export function sortTags(tags: string[]): string[] {
60 return [...tags].sort((a, b) => {
61 if (a === 'latest') return -1
62 if (b === 'latest') return 1
63 return a.localeCompare(b)
64 })
65}
66
67/**
68 * Build a map from version strings to their associated dist-tags
69 * Handles the case where multiple tags point to the same version
70 * @param distTags - Object mapping tag names to version strings
71 * @returns Map from version to sorted array of tags
72 */
73export function buildVersionToTagsMap(distTags: Record<string, string>): Map<string, string[]> {
74 const map = new Map<string, string[]>()
75
76 for (const [tag, version] of Object.entries(distTags)) {
77 const existing = map.get(version)
78 if (existing) {
79 existing.push(tag)
80 } else {
81 map.set(version, [tag])
82 }
83 }
84
85 // Sort tags within each version
86 for (const tags of map.values()) {
87 tags.sort((a, b) => {
88 if (a === 'latest') return -1
89 if (b === 'latest') return 1
90 return a.localeCompare(b)
91 })
92 }
93
94 return map
95}
96
97/** A tagged version row for display */
98export interface TaggedVersionRow {
99 /** Unique identifier for the row */
100 id: string
101 /** Primary tag (first in sorted order, used for expand/collapse) */
102 primaryTag: string
103 /** All tags for this version */
104 tags: string[]
105 /** The version string */
106 version: string
107}
108
109/**
110 * Build deduplicated rows for tagged versions
111 * Each unique version appears once with all its tags
112 * @param distTags - Object mapping tag names to version strings
113 * @returns Array of rows sorted by version (descending)
114 */
115export function buildTaggedVersionRows(distTags: Record<string, string>): TaggedVersionRow[] {
116 const versionToTags = buildVersionToTagsMap(distTags)
117
118 return Array.from(versionToTags.entries())
119 .map(([version, tags]) => ({
120 id: `version:${version}`,
121 primaryTag: tags[0]!,
122 tags,
123 version,
124 }))
125 .sort((a, b) => compare(b.version, a.version))
126}
127
128/**
129 * Filter tags to exclude those already shown in a parent context
130 * Useful when showing nested versions that shouldn't repeat parent tags
131 * @param tags - Tags to filter
132 * @param excludeTags - Tags to exclude
133 * @returns Filtered array of tags
134 */
135export function filterExcludedTags(tags: string[], excludeTags: string[]): string[] {
136 const excludeSet = new Set(excludeTags)
137 return tags.filter(tag => !excludeSet.has(tag))
138}
139
140/**
141 * Get a grouping key for a version that handles 0.x versions specially.
142 *
143 * Per semver spec, versions below 1.0.0 can have breaking changes in minor bumps,
144 * so 0.9.x should be in a separate group from 0.10.x.
145 *
146 * @param version - The version string (e.g., "0.9.3", "1.2.3")
147 * @returns A grouping key string (e.g., "0.9", "1")
148 */
149export function getVersionGroupKey(version: string): string {
150 const parsed = parseVersion(version)
151 if (parsed.major === 0) {
152 // For 0.x versions, group by major.minor
153 return `0.${parsed.minor}`
154 }
155 // For 1.x+, group by major only
156 return String(parsed.major)
157}
158
159/**
160 * Get a display label for a version group key.
161 *
162 * @param groupKey - The group key from getVersionGroupKey()
163 * @returns A display label (e.g., "0.9.x", "1.x")
164 */
165export function getVersionGroupLabel(groupKey: string): string {
166 return `${groupKey}.x`
167}
168
169/**
170 * Check if two versions belong to the same version group.
171 *
172 * For versions >= 1.0.0, same major = same group.
173 * For versions < 1.0.0, same major.minor = same group.
174 *
175 * @param versionA - First version string
176 * @param versionB - Second version string
177 * @returns true if both versions are in the same group
178 */
179export function isSameVersionGroup(versionA: string, versionB: string): boolean {
180 return getVersionGroupKey(versionA) === getVersionGroupKey(versionB)
181}
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 */
192export 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}