[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: handle multiple tags per version and improve display

+647 -155
+153 -155
app/components/PackageVersions.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { PackumentVersion, PackageVersionInfo } from '#shared/types' 3 3 import type { RouteLocationRaw } from 'vue-router' 4 + import { 5 + buildVersionToTagsMap, 6 + compareVersions, 7 + filterExcludedTags, 8 + getPrereleaseChannel, 9 + parseVersion, 10 + } from '~/utils/versions' 4 11 5 12 const props = defineProps<{ 6 13 packageName: string ··· 13 20 interface VersionDisplay { 14 21 version: string 15 22 time?: string 16 - tag?: string 23 + tags?: string[] 17 24 hasProvenance: boolean 18 25 } 19 26 ··· 24 31 return !!dist.attestations 25 32 } 26 33 27 - // Parse semver 28 - function parseVersion(version: string): { 29 - major: number 30 - minor: number 31 - patch: number 32 - prerelease: string 33 - } { 34 - const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/) 35 - if (!match) return { major: 0, minor: 0, patch: 0, prerelease: '' } 36 - return { 37 - major: Number(match[1]), 38 - minor: Number(match[2]), 39 - patch: Number(match[3]), 40 - prerelease: match[4] ?? '', 41 - } 42 - } 43 - 44 - // Compare versions (descending - higher version = smaller result for sort) 45 - function compareVersions(a: string, b: string): number { 46 - const va = parseVersion(a) 47 - const vb = parseVersion(b) 48 - 49 - if (va.major !== vb.major) return va.major - vb.major 50 - if (va.minor !== vb.minor) return va.minor - vb.minor 51 - if (va.patch !== vb.patch) return va.patch - vb.patch 52 - 53 - if (va.prerelease && vb.prerelease) return va.prerelease.localeCompare(vb.prerelease) 54 - if (va.prerelease) return -1 55 - if (vb.prerelease) return 1 56 - 57 - return 0 58 - } 59 - 60 34 // Build route object for package version link 61 35 function versionRoute(version: string): RouteLocationRaw { 62 36 return { ··· 65 39 } 66 40 } 67 41 68 - // Get prerelease channel or empty string for stable 69 - function getPrereleaseChannel(version: string): string { 70 - const parsed = parseVersion(version) 71 - if (!parsed.prerelease) return '' 72 - const match = parsed.prerelease.match(/^([a-z]+)/i) 73 - return match ? match[1]!.toLowerCase() : '' 74 - } 42 + // Version to tags lookup (supports multiple tags per version) 43 + const versionToTags = computed(() => buildVersionToTagsMap(props.distTags)) 75 44 76 - // Version to tag lookup 77 - const versionToTag = computed(() => { 78 - const map = new Map<string, string>() 45 + // Initial tag rows derived from props (SSR-safe) 46 + // Deduplicates so each version appears only once, with all its tags 47 + const initialTagRows = computed(() => { 48 + // Group tags by version with their metadata 49 + const versionMap = new Map< 50 + string, 51 + { tags: string[]; versionData: PackumentVersion | undefined } 52 + >() 79 53 for (const [tag, version] of Object.entries(props.distTags)) { 80 - const existing = map.get(version) 81 - if (!existing || tag === 'latest' || (tag.length < existing.length && existing !== 'latest')) { 82 - map.set(version, tag) 54 + const existing = versionMap.get(version) 55 + if (existing) { 56 + existing.tags.push(tag) 57 + } else { 58 + versionMap.set(version, { 59 + tags: [tag], 60 + versionData: props.versions[version], 61 + }) 83 62 } 84 63 } 85 - return map 86 - }) 87 64 88 - // Initial tag rows derived from props (SSR-safe) 89 - const initialTagRows = computed(() => { 90 - return Object.entries(props.distTags) 91 - .map(([tag, version]) => { 92 - const versionData = props.versions[version] 93 - return { 94 - id: `tag:${tag}`, 95 - tag, 96 - primaryVersion: { 97 - version, 98 - time: props.time[version], 99 - tag, 100 - hasProvenance: hasProvenance(versionData), 101 - } as VersionDisplay, 102 - } 65 + // Sort tags within each version: 'latest' first, then alphabetically 66 + for (const entry of versionMap.values()) { 67 + entry.tags.sort((a, b) => { 68 + if (a === 'latest') return -1 69 + if (b === 'latest') return 1 70 + return a.localeCompare(b) 103 71 }) 72 + } 73 + 74 + // Convert to rows, using the first (most important) tag as the primary 75 + return Array.from(versionMap.entries()) 76 + .map(([version, { tags, versionData }]) => ({ 77 + id: `version:${version}`, 78 + tag: tags[0]!, // Primary tag for expand/collapse logic 79 + tags, // All tags for this version 80 + primaryVersion: { 81 + version, 82 + time: props.time[version], 83 + tags, 84 + hasProvenance: hasProvenance(versionData), 85 + } as VersionDisplay, 86 + })) 104 87 .sort((a, b) => compareVersions(b.primaryVersion.version, a.primaryVersion.version)) 105 88 }) 106 89 ··· 193 176 .map(v => ({ 194 177 version: v.version, 195 178 time: v.time, 196 - tag: versionToTag.value.get(v.version), 179 + tags: versionToTags.value.get(v.version), 197 180 hasProvenance: v.hasProvenance, 198 181 })) 199 182 ··· 217 200 byMajor.get(major)!.push({ 218 201 version: v.version, 219 202 time: v.time, 220 - tag: versionToTag.value.get(v.version), 203 + tags: versionToTags.value.get(v.version), 221 204 hasProvenance: v.hasProvenance, 222 205 }) 223 206 } ··· 339 322 <span v-else class="w-4" /> 340 323 341 324 <!-- Version info --> 342 - <div class="flex-1 flex items-center justify-between py-1.5 text-sm gap-2 min-w-0"> 343 - <div class="flex items-center gap-2 min-w-0"> 325 + <div class="flex-1 py-1.5 min-w-0"> 326 + <div class="flex items-center justify-between gap-2"> 344 327 <NuxtLink 345 328 :to="versionRoute(row.primaryVersion.version)" 346 - class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate" 329 + class="font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 truncate" 347 330 > 348 331 {{ row.primaryVersion.version }} 349 332 </NuxtLink> 333 + <div class="flex items-center gap-2 shrink-0"> 334 + <time 335 + v-if="row.primaryVersion.time" 336 + :datetime="row.primaryVersion.time" 337 + class="text-xs text-fg-subtle" 338 + > 339 + {{ formatDate(row.primaryVersion.time) }} 340 + </time> 341 + <ProvenanceBadge 342 + v-if="row.primaryVersion.hasProvenance" 343 + :package-name="packageName" 344 + :version="row.primaryVersion.version" 345 + compact 346 + /> 347 + </div> 348 + </div> 349 + <div v-if="row.tags.length" class="flex items-center gap-1 mt-0.5"> 350 350 <span 351 - class="px-1.5 py-0.5 text-[10px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 351 + v-for="tag in row.tags" 352 + :key="tag" 353 + class="text-[9px] font-semibold text-fg-subtle uppercase tracking-wide" 352 354 > 353 - {{ row.tag }} 355 + {{ tag }} 354 356 </span> 355 357 </div> 356 - <div class="flex items-center gap-2 shrink-0"> 357 - <time 358 - v-if="row.primaryVersion.time" 359 - :datetime="row.primaryVersion.time" 360 - class="text-xs text-fg-subtle" 361 - > 362 - {{ formatDate(row.primaryVersion.time) }} 363 - </time> 364 - <ProvenanceBadge 365 - v-if="row.primaryVersion.hasProvenance" 366 - :package-name="packageName" 367 - :version="row.primaryVersion.version" 368 - compact 369 - /> 370 - </div> 371 358 </div> 372 359 </div> 373 360 ··· 376 363 v-if="expandedTags.has(row.tag) && getTagVersions(row.tag).length > 1" 377 364 class="ml-4 pl-2 border-l border-border space-y-0.5" 378 365 > 379 - <div 380 - v-for="v in getTagVersions(row.tag).slice(1)" 381 - :key="v.version" 382 - class="flex items-center justify-between py-1 text-sm gap-2" 383 - > 384 - <div class="flex items-center gap-2 min-w-0"> 366 + <div v-for="v in getTagVersions(row.tag).slice(1)" :key="v.version" class="py-1"> 367 + <div class="flex items-center justify-between gap-2"> 385 368 <NuxtLink 386 369 :to="versionRoute(v.version)" 387 370 class="font-mono text-xs text-fg-subtle hover:text-fg-muted transition-colors duration-200 truncate" 388 371 > 389 372 {{ v.version }} 390 373 </NuxtLink> 374 + <div class="flex items-center gap-2 shrink-0"> 375 + <time v-if="v.time" :datetime="v.time" class="text-[10px] text-fg-subtle"> 376 + {{ formatDate(v.time) }} 377 + </time> 378 + <ProvenanceBadge 379 + v-if="v.hasProvenance" 380 + :package-name="packageName" 381 + :version="v.version" 382 + compact 383 + /> 384 + </div> 385 + </div> 386 + <div 387 + v-if="v.tags?.length && filterExcludedTags(v.tags, row.tags).length" 388 + class="flex items-center gap-1 mt-0.5" 389 + > 391 390 <span 392 - v-if="v.tag && v.tag !== row.tag" 393 - class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 391 + v-for="tag in filterExcludedTags(v.tags, row.tags)" 392 + :key="tag" 393 + class="text-[8px] font-semibold text-fg-subtle uppercase tracking-wide" 394 394 > 395 - {{ v.tag }} 395 + {{ tag }} 396 396 </span> 397 397 </div> 398 - <div class="flex items-center gap-2 shrink-0"> 399 - <time v-if="v.time" :datetime="v.time" class="text-[10px] text-fg-subtle"> 400 - {{ formatDate(v.time) }} 401 - </time> 402 - <ProvenanceBadge 403 - v-if="v.hasProvenance" 404 - :package-name="packageName" 405 - :version="v.version" 406 - compact 407 - /> 408 - </div> 409 398 </div> 410 399 </div> 411 400 </div> ··· 439 428 <button 440 429 v-if="group.versions.length > 1" 441 430 type="button" 442 - class="flex items-center gap-2 w-full text-left py-1" 431 + class="w-full text-left py-1" 443 432 :aria-expanded="group.expanded" 444 433 @click="toggleMajorGroup(groupIndex)" 445 434 > 446 - <span 447 - class="w-3 h-3 transition-transform duration-200 text-fg-subtle" 448 - :class="group.expanded ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right'" 449 - /> 450 - <span class="font-mono text-xs text-fg-muted"> 451 - {{ group.versions[0]?.version }} 452 - </span> 453 - <span 454 - v-if="group.versions[0]?.tag" 455 - class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 456 - > 457 - {{ group.versions[0].tag }} 458 - </span> 435 + <div class="flex items-center gap-2"> 436 + <span 437 + class="w-3 h-3 transition-transform duration-200 text-fg-subtle" 438 + :class="group.expanded ? 'i-carbon-chevron-down' : 'i-carbon-chevron-right'" 439 + /> 440 + <span class="font-mono text-xs text-fg-muted"> 441 + {{ group.versions[0]?.version }} 442 + </span> 443 + </div> 444 + <div v-if="group.versions[0]?.tags?.length" class="flex items-center gap-1 ml-5"> 445 + <span 446 + v-for="tag in group.versions[0].tags" 447 + :key="tag" 448 + class="text-[8px] font-semibold text-fg-subtle uppercase tracking-wide" 449 + > 450 + {{ tag }} 451 + </span> 452 + </div> 459 453 </button> 460 454 <!-- Single version (no expand needed) --> 461 - <div v-else class="flex items-center gap-2 py-1"> 462 - <span class="w-3" /> 463 - <NuxtLink 464 - v-if="group.versions[0]" 465 - :to="versionRoute(group.versions[0].version)" 466 - class="font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200" 467 - > 468 - {{ group.versions[0].version }} 469 - </NuxtLink> 470 - <span 471 - v-if="group.versions[0]?.tag" 472 - class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 473 - > 474 - {{ group.versions[0].tag }} 475 - </span> 455 + <div v-else class="py-1"> 456 + <div class="flex items-center gap-2"> 457 + <span class="w-3" /> 458 + <NuxtLink 459 + v-if="group.versions[0]" 460 + :to="versionRoute(group.versions[0].version)" 461 + class="font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200" 462 + > 463 + {{ group.versions[0].version }} 464 + </NuxtLink> 465 + </div> 466 + <div v-if="group.versions[0]?.tags?.length" class="flex items-center gap-1 ml-5"> 467 + <span 468 + v-for="tag in group.versions[0].tags" 469 + :key="tag" 470 + class="text-[8px] font-semibold text-fg-subtle uppercase tracking-wide" 471 + > 472 + {{ tag }} 473 + </span> 474 + </div> 476 475 </div> 477 476 478 477 <!-- Major group versions --> 479 478 <div v-if="group.expanded && group.versions.length > 1" class="ml-5 space-y-0.5"> 480 - <div 481 - v-for="v in group.versions.slice(1)" 482 - :key="v.version" 483 - class="flex items-center justify-between py-1 text-sm gap-2" 484 - > 485 - <div class="flex items-center gap-2 min-w-0"> 479 + <div v-for="v in group.versions.slice(1)" :key="v.version" class="py-1"> 480 + <div class="flex items-center justify-between gap-2"> 486 481 <NuxtLink 487 482 :to="versionRoute(v.version)" 488 483 class="font-mono text-xs text-fg-subtle hover:text-fg-muted transition-colors duration-200 truncate" 489 484 > 490 485 {{ v.version }} 491 486 </NuxtLink> 487 + <div class="flex items-center gap-2 shrink-0"> 488 + <time v-if="v.time" :datetime="v.time" class="text-[10px] text-fg-subtle"> 489 + {{ formatDate(v.time) }} 490 + </time> 491 + <ProvenanceBadge 492 + v-if="v.hasProvenance" 493 + :package-name="packageName" 494 + :version="v.version" 495 + compact 496 + /> 497 + </div> 498 + </div> 499 + <div v-if="v.tags?.length" class="flex items-center gap-1 mt-0.5"> 492 500 <span 493 - v-if="v.tag" 494 - class="px-1 py-0.5 text-[9px] font-semibold text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 501 + v-for="tag in v.tags" 502 + :key="tag" 503 + class="text-[8px] font-semibold text-fg-subtle uppercase tracking-wide" 495 504 > 496 - {{ v.tag }} 505 + {{ tag }} 497 506 </span> 498 - </div> 499 - <div class="flex items-center gap-2 shrink-0"> 500 - <time v-if="v.time" :datetime="v.time" class="text-[10px] text-fg-subtle"> 501 - {{ formatDate(v.time) }} 502 - </time> 503 - <ProvenanceBadge 504 - v-if="v.hasProvenance" 505 - :package-name="packageName" 506 - :version="v.version" 507 - compact 508 - /> 509 507 </div> 510 508 </div> 511 509 </div>
+148
app/utils/versions.ts
··· 1 + /** 2 + * Utilities for handling npm package versions and dist-tags 3 + */ 4 + 5 + /** Parsed semver version components */ 6 + export interface ParsedVersion { 7 + major: number 8 + minor: number 9 + patch: number 10 + prerelease: string 11 + } 12 + 13 + /** 14 + * Parse a semver version string into its components 15 + * @param version - The version string (e.g., "1.2.3" or "1.0.0-beta.1") 16 + * @returns Parsed version object with major, minor, patch, and prerelease 17 + */ 18 + export function parseVersion(version: string): ParsedVersion { 19 + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/) 20 + if (!match) return { major: 0, minor: 0, patch: 0, prerelease: '' } 21 + return { 22 + major: Number(match[1]), 23 + minor: Number(match[2]), 24 + patch: Number(match[3]), 25 + prerelease: match[4] ?? '', 26 + } 27 + } 28 + 29 + /** 30 + * Compare two semver versions for sorting 31 + * Returns positive if a > b, negative if a < b, 0 if equal 32 + * @param a - First version string 33 + * @param b - Second version string 34 + * @returns Comparison result for sorting 35 + */ 36 + export function compareVersions(a: string, b: string): number { 37 + const va = parseVersion(a) 38 + const vb = parseVersion(b) 39 + 40 + if (va.major !== vb.major) return va.major - vb.major 41 + if (va.minor !== vb.minor) return va.minor - vb.minor 42 + if (va.patch !== vb.patch) return va.patch - vb.patch 43 + 44 + // Stable versions (no prerelease) are greater than prereleases 45 + if (va.prerelease && vb.prerelease) return va.prerelease.localeCompare(vb.prerelease) 46 + if (va.prerelease) return -1 47 + if (vb.prerelease) return 1 48 + 49 + return 0 50 + } 51 + 52 + /** 53 + * Extract the prerelease channel from a version string 54 + * @param version - The version string (e.g., "1.0.0-beta.1") 55 + * @returns The channel name (e.g., "beta") or empty string for stable versions 56 + */ 57 + export function getPrereleaseChannel(version: string): string { 58 + const parsed = parseVersion(version) 59 + if (!parsed.prerelease) return '' 60 + const match = parsed.prerelease.match(/^([a-z]+)/i) 61 + return match ? match[1]!.toLowerCase() : '' 62 + } 63 + 64 + /** 65 + * Sort tags with 'latest' first, then alphabetically 66 + * @param tags - Array of tag names 67 + * @returns New sorted array 68 + */ 69 + export function sortTags(tags: string[]): string[] { 70 + return [...tags].sort((a, b) => { 71 + if (a === 'latest') return -1 72 + if (b === 'latest') return 1 73 + return a.localeCompare(b) 74 + }) 75 + } 76 + 77 + /** 78 + * Build a map from version strings to their associated dist-tags 79 + * Handles the case where multiple tags point to the same version 80 + * @param distTags - Object mapping tag names to version strings 81 + * @returns Map from version to sorted array of tags 82 + */ 83 + export function buildVersionToTagsMap(distTags: Record<string, string>): Map<string, string[]> { 84 + const map = new Map<string, string[]>() 85 + 86 + for (const [tag, version] of Object.entries(distTags)) { 87 + const existing = map.get(version) 88 + if (existing) { 89 + existing.push(tag) 90 + } else { 91 + map.set(version, [tag]) 92 + } 93 + } 94 + 95 + // Sort tags within each version 96 + for (const tags of map.values()) { 97 + tags.sort((a, b) => { 98 + if (a === 'latest') return -1 99 + if (b === 'latest') return 1 100 + return a.localeCompare(b) 101 + }) 102 + } 103 + 104 + return map 105 + } 106 + 107 + /** A tagged version row for display */ 108 + export interface TaggedVersionRow { 109 + /** Unique identifier for the row */ 110 + id: string 111 + /** Primary tag (first in sorted order, used for expand/collapse) */ 112 + primaryTag: string 113 + /** All tags for this version */ 114 + tags: string[] 115 + /** The version string */ 116 + version: string 117 + } 118 + 119 + /** 120 + * Build deduplicated rows for tagged versions 121 + * Each unique version appears once with all its tags 122 + * @param distTags - Object mapping tag names to version strings 123 + * @returns Array of rows sorted by version (descending) 124 + */ 125 + export function buildTaggedVersionRows(distTags: Record<string, string>): TaggedVersionRow[] { 126 + const versionToTags = buildVersionToTagsMap(distTags) 127 + 128 + return Array.from(versionToTags.entries()) 129 + .map(([version, tags]) => ({ 130 + id: `version:${version}`, 131 + primaryTag: tags[0]!, 132 + tags, 133 + version, 134 + })) 135 + .sort((a, b) => compareVersions(b.version, a.version)) 136 + } 137 + 138 + /** 139 + * Filter tags to exclude those already shown in a parent context 140 + * Useful when showing nested versions that shouldn't repeat parent tags 141 + * @param tags - Tags to filter 142 + * @param excludeTags - Tags to exclude 143 + * @returns Filtered array of tags 144 + */ 145 + export function filterExcludedTags(tags: string[], excludeTags: string[]): string[] { 146 + const excludeSet = new Set(excludeTags) 147 + return tags.filter(tag => !excludeSet.has(tag)) 148 + }
+346
test/unit/versions.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { 3 + buildTaggedVersionRows, 4 + buildVersionToTagsMap, 5 + compareVersions, 6 + filterExcludedTags, 7 + getPrereleaseChannel, 8 + parseVersion, 9 + sortTags, 10 + } from '../../app/utils/versions' 11 + 12 + describe('parseVersion', () => { 13 + it('parses stable versions', () => { 14 + expect(parseVersion('1.2.3')).toEqual({ 15 + major: 1, 16 + minor: 2, 17 + patch: 3, 18 + prerelease: '', 19 + }) 20 + }) 21 + 22 + it('parses prerelease versions', () => { 23 + expect(parseVersion('1.0.0-beta.1')).toEqual({ 24 + major: 1, 25 + minor: 0, 26 + patch: 0, 27 + prerelease: 'beta.1', 28 + }) 29 + }) 30 + 31 + it('handles invalid versions gracefully', () => { 32 + expect(parseVersion('invalid')).toEqual({ 33 + major: 0, 34 + minor: 0, 35 + patch: 0, 36 + prerelease: '', 37 + }) 38 + }) 39 + 40 + it('parses TypeScript-style versions', () => { 41 + // TypeScript uses versions like 5.8.0-beta, 5.8.0-rc 42 + expect(parseVersion('5.8.0-beta')).toEqual({ 43 + major: 5, 44 + minor: 8, 45 + patch: 0, 46 + prerelease: 'beta', 47 + }) 48 + }) 49 + 50 + it('parses Next.js canary versions', () => { 51 + // Next.js uses versions like 15.3.0-canary.1 52 + expect(parseVersion('15.3.0-canary.1')).toEqual({ 53 + major: 15, 54 + minor: 3, 55 + patch: 0, 56 + prerelease: 'canary.1', 57 + }) 58 + }) 59 + }) 60 + 61 + describe('compareVersions', () => { 62 + it('compares major versions', () => { 63 + expect(compareVersions('2.0.0', '1.0.0')).toBeGreaterThan(0) 64 + expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0) 65 + }) 66 + 67 + it('compares minor versions', () => { 68 + expect(compareVersions('1.2.0', '1.1.0')).toBeGreaterThan(0) 69 + expect(compareVersions('1.1.0', '1.2.0')).toBeLessThan(0) 70 + }) 71 + 72 + it('compares patch versions', () => { 73 + expect(compareVersions('1.0.2', '1.0.1')).toBeGreaterThan(0) 74 + expect(compareVersions('1.0.1', '1.0.2')).toBeLessThan(0) 75 + }) 76 + 77 + it('ranks stable above prerelease', () => { 78 + expect(compareVersions('1.0.0', '1.0.0-beta.1')).toBeGreaterThan(0) 79 + expect(compareVersions('1.0.0-beta.1', '1.0.0')).toBeLessThan(0) 80 + }) 81 + 82 + it('compares prereleases alphabetically', () => { 83 + expect(compareVersions('1.0.0-beta.1', '1.0.0-alpha.1')).toBeGreaterThan(0) 84 + expect(compareVersions('1.0.0-alpha.1', '1.0.0-beta.1')).toBeLessThan(0) 85 + }) 86 + 87 + it('returns 0 for equal versions', () => { 88 + expect(compareVersions('1.0.0', '1.0.0')).toBe(0) 89 + expect(compareVersions('1.0.0-beta.1', '1.0.0-beta.1')).toBe(0) 90 + }) 91 + 92 + it('sorts Nuxt versions correctly', () => { 93 + const versions = ['3.21.0', '4.0.0-alpha.4', '4.0.0-rc.0', '4.3.0'] 94 + const sorted = [...versions].sort((a, b) => compareVersions(b, a)) 95 + expect(sorted).toEqual(['4.3.0', '4.0.0-rc.0', '4.0.0-alpha.4', '3.21.0']) 96 + }) 97 + }) 98 + 99 + describe('getPrereleaseChannel', () => { 100 + it('returns empty string for stable versions', () => { 101 + expect(getPrereleaseChannel('1.0.0')).toBe('') 102 + }) 103 + 104 + it('extracts beta channel', () => { 105 + expect(getPrereleaseChannel('1.0.0-beta.1')).toBe('beta') 106 + }) 107 + 108 + it('extracts alpha channel', () => { 109 + expect(getPrereleaseChannel('1.0.0-alpha.1')).toBe('alpha') 110 + }) 111 + 112 + it('extracts rc channel', () => { 113 + expect(getPrereleaseChannel('4.0.0-rc.0')).toBe('rc') 114 + }) 115 + 116 + it('extracts canary channel (Next.js style)', () => { 117 + expect(getPrereleaseChannel('15.3.0-canary.1')).toBe('canary') 118 + }) 119 + 120 + it('handles versions with just channel name (TypeScript style)', () => { 121 + expect(getPrereleaseChannel('5.8.0-beta')).toBe('beta') 122 + }) 123 + }) 124 + 125 + describe('sortTags', () => { 126 + it('puts latest first', () => { 127 + expect(sortTags(['beta', 'latest', 'alpha'])).toEqual(['latest', 'alpha', 'beta']) 128 + }) 129 + 130 + it('sorts alphabetically when no latest', () => { 131 + expect(sortTags(['beta', 'canary', 'alpha'])).toEqual(['alpha', 'beta', 'canary']) 132 + }) 133 + 134 + it('handles single tag', () => { 135 + expect(sortTags(['latest'])).toEqual(['latest']) 136 + }) 137 + 138 + it('handles empty array', () => { 139 + expect(sortTags([])).toEqual([]) 140 + }) 141 + 142 + it('does not mutate original array', () => { 143 + const original = ['beta', 'latest'] 144 + sortTags(original) 145 + expect(original).toEqual(['beta', 'latest']) 146 + }) 147 + }) 148 + 149 + describe('buildVersionToTagsMap', () => { 150 + it('builds map from simple dist-tags', () => { 151 + const distTags = { 152 + latest: '1.0.0', 153 + beta: '2.0.0-beta.1', 154 + } 155 + const map = buildVersionToTagsMap(distTags) 156 + expect(map.get('1.0.0')).toEqual(['latest']) 157 + expect(map.get('2.0.0-beta.1')).toEqual(['beta']) 158 + }) 159 + 160 + it('groups multiple tags pointing to same version', () => { 161 + const distTags = { 162 + latest: '1.0.0', 163 + stable: '1.0.0', 164 + lts: '1.0.0', 165 + } 166 + const map = buildVersionToTagsMap(distTags) 167 + // Should be sorted with latest first, then alphabetically 168 + expect(map.get('1.0.0')).toEqual(['latest', 'lts', 'stable']) 169 + }) 170 + 171 + it('handles Nuxt dist-tags', () => { 172 + // Real Nuxt dist-tags structure 173 + const distTags = { 174 + '1x': '1.4.5', 175 + '2x': '2.18.1', 176 + 'alpha': '4.0.0-alpha.4', 177 + 'rc': '4.0.0-rc.0', 178 + '3x': '3.21.0', 179 + 'latest': '4.3.0', 180 + } 181 + const map = buildVersionToTagsMap(distTags) 182 + expect(map.get('4.3.0')).toEqual(['latest']) 183 + expect(map.get('3.21.0')).toEqual(['3x']) 184 + expect(map.size).toBe(6) 185 + }) 186 + 187 + it('handles TypeScript dist-tags with overlapping versions', () => { 188 + // Simulating a scenario where latest and next point to same version 189 + const distTags = { 190 + latest: '5.8.3', 191 + next: '5.8.3', 192 + beta: '5.9.0-beta', 193 + rc: '5.9.0-rc', 194 + } 195 + const map = buildVersionToTagsMap(distTags) 196 + expect(map.get('5.8.3')).toEqual(['latest', 'next']) 197 + expect(map.get('5.9.0-beta')).toEqual(['beta']) 198 + }) 199 + 200 + it('handles Next.js dist-tags', () => { 201 + // Real Next.js dist-tags structure 202 + const distTags = { 203 + 'latest': '15.2.4', 204 + 'canary': '15.3.0-canary.49', 205 + 'rc': '15.2.0-rc.2', 206 + 'experimental-react': '0.0.0-experimental-react', 207 + } 208 + const map = buildVersionToTagsMap(distTags) 209 + expect(map.get('15.2.4')).toEqual(['latest']) 210 + expect(map.get('15.3.0-canary.49')).toEqual(['canary']) 211 + }) 212 + 213 + it('handles Vue dist-tags', () => { 214 + // Vue uses v3-latest, etc. 215 + const distTags = { 216 + 'latest': '3.5.13', 217 + 'next': '3.5.13', 218 + 'v2-latest': '2.7.16', 219 + 'csp': '1.0.28-csp', 220 + } 221 + const map = buildVersionToTagsMap(distTags) 222 + // latest and next both point to 3.5.13 223 + expect(map.get('3.5.13')).toEqual(['latest', 'next']) 224 + expect(map.get('2.7.16')).toEqual(['v2-latest']) 225 + }) 226 + 227 + it('handles React dist-tags', () => { 228 + const distTags = { 229 + latest: '19.1.0', 230 + next: '19.1.0', 231 + canary: '19.1.0-canary-xyz', 232 + experimental: '0.0.0-experimental-xyz', 233 + rc: '19.0.0-rc.1', 234 + } 235 + const map = buildVersionToTagsMap(distTags) 236 + // latest and next both point to same version 237 + expect(map.get('19.1.0')).toEqual(['latest', 'next']) 238 + }) 239 + }) 240 + 241 + describe('buildTaggedVersionRows', () => { 242 + it('builds rows sorted by version descending', () => { 243 + const distTags = { 244 + latest: '2.0.0', 245 + beta: '3.0.0-beta.1', 246 + legacy: '1.0.0', 247 + } 248 + const rows = buildTaggedVersionRows(distTags) 249 + expect(rows.map(r => r.version)).toEqual(['3.0.0-beta.1', '2.0.0', '1.0.0']) 250 + }) 251 + 252 + it('deduplicates versions with multiple tags', () => { 253 + const distTags = { 254 + latest: '1.0.0', 255 + stable: '1.0.0', 256 + beta: '2.0.0-beta.1', 257 + } 258 + const rows = buildTaggedVersionRows(distTags) 259 + expect(rows).toHaveLength(2) 260 + expect(rows[0]).toEqual({ 261 + id: 'version:2.0.0-beta.1', 262 + primaryTag: 'beta', 263 + tags: ['beta'], 264 + version: '2.0.0-beta.1', 265 + }) 266 + expect(rows[1]).toEqual({ 267 + id: 'version:1.0.0', 268 + primaryTag: 'latest', 269 + tags: ['latest', 'stable'], 270 + version: '1.0.0', 271 + }) 272 + }) 273 + 274 + it('uses latest as primary tag when present', () => { 275 + const distTags = { 276 + stable: '1.0.0', 277 + latest: '1.0.0', 278 + lts: '1.0.0', 279 + } 280 + const rows = buildTaggedVersionRows(distTags) 281 + expect(rows[0]!.primaryTag).toBe('latest') 282 + expect(rows[0]!.tags).toEqual(['latest', 'lts', 'stable']) 283 + }) 284 + 285 + it('handles Vue scenario with latest and next on same version', () => { 286 + const distTags = { 287 + 'latest': '3.5.13', 288 + 'next': '3.5.13', 289 + 'v2-latest': '2.7.16', 290 + } 291 + const rows = buildTaggedVersionRows(distTags) 292 + expect(rows).toHaveLength(2) 293 + // 3.5.13 should come first (higher version) 294 + expect(rows[0]).toEqual({ 295 + id: 'version:3.5.13', 296 + primaryTag: 'latest', 297 + tags: ['latest', 'next'], 298 + version: '3.5.13', 299 + }) 300 + }) 301 + 302 + it('handles Nuxt scenario', () => { 303 + const distTags = { 304 + '1x': '1.4.5', 305 + '2x': '2.18.1', 306 + 'alpha': '4.0.0-alpha.4', 307 + 'rc': '4.0.0-rc.0', 308 + '3x': '3.21.0', 309 + 'latest': '4.3.0', 310 + } 311 + const rows = buildTaggedVersionRows(distTags) 312 + expect(rows).toHaveLength(6) 313 + // Check order: 4.3.0 > 4.0.0-rc.0 > 4.0.0-alpha.4 > 3.21.0 > 2.18.1 > 1.4.5 314 + expect(rows.map(r => r.version)).toEqual([ 315 + '4.3.0', 316 + '4.0.0-rc.0', 317 + '4.0.0-alpha.4', 318 + '3.21.0', 319 + '2.18.1', 320 + '1.4.5', 321 + ]) 322 + expect(rows[0]!.tags).toEqual(['latest']) 323 + }) 324 + }) 325 + 326 + describe('filterExcludedTags', () => { 327 + it('filters out excluded tags', () => { 328 + expect(filterExcludedTags(['latest', 'beta', 'rc'], ['latest'])).toEqual(['beta', 'rc']) 329 + }) 330 + 331 + it('filters multiple excluded tags', () => { 332 + expect(filterExcludedTags(['latest', 'next', 'beta'], ['latest', 'next'])).toEqual(['beta']) 333 + }) 334 + 335 + it('returns all tags if none excluded', () => { 336 + expect(filterExcludedTags(['latest', 'beta'], [])).toEqual(['latest', 'beta']) 337 + }) 338 + 339 + it('returns empty if all excluded', () => { 340 + expect(filterExcludedTags(['latest'], ['latest'])).toEqual([]) 341 + }) 342 + 343 + it('handles non-matching exclusions', () => { 344 + expect(filterExcludedTags(['beta', 'rc'], ['latest'])).toEqual(['beta', 'rc']) 345 + }) 346 + })