[READ-ONLY] a fast, modern browser for the npm registry
at main 209 lines 6.3 kB view raw
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}