Skip to content

Commit 5e2e09e

Browse files
authored
fix: tag sorting on package versions page (#2105)
1 parent a9d69ae commit 5e2e09e

File tree

3 files changed

+188
-16
lines changed

3 files changed

+188
-16
lines changed

app/pages/package/[[org]]/[name]/versions.vue

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { compare, validRange } from 'semver'
55
import {
66
buildVersionToTagsMap,
77
buildTaggedVersionRows,
8+
compareTagRows,
9+
compareVersionGroupKeys,
810
filterVersions,
911
getVersionGroupKey,
1012
getVersionGroupLabel,
@@ -64,7 +66,14 @@ async function ensureFullDataLoaded() {
6466
// ─── Derived data ─────────────────────────────────────────────────────────────
6567
6668
const versionToTagsMap = computed(() => buildVersionToTagsMap(distTags.value))
69+
6770
const tagRows = computed(() => buildTaggedVersionRows(distTags.value))
71+
const latestTagRow = computed(() => tagRows.value.find(r => r.tags.includes('latest')) ?? null)
72+
const otherTagRows = computed(() =>
73+
tagRows.value
74+
.filter(r => !r.tags.includes('latest'))
75+
.sort((rowA, rowB) => compareTagRows(rowA, rowB, versionTimes.value)),
76+
)
6877
6978
function getVersionTime(version: string): string | undefined {
7079
return versionTimes.value[version]
@@ -84,12 +93,7 @@ const versionGroups = computed(() => {
8493
}
8594
8695
return Array.from(byKey.keys())
87-
.sort((a, b) => {
88-
const [aMajor, aMinor] = a.split('.').map(Number)
89-
const [bMajor, bMinor] = b.split('.').map(Number)
90-
if (aMajor !== bMajor) return (bMajor ?? 0) - (aMajor ?? 0)
91-
return (bMinor ?? -1) - (aMinor ?? -1)
92-
})
96+
.sort(compareVersionGroupKeys)
9397
.map(groupKey => ({
9498
groupKey,
9599
label: getVersionGroupLabel(groupKey),
@@ -215,39 +219,39 @@ const flatItems = computed<FlatItem[]>(() => {
215219

216220
<!-- Latest — featured card -->
217221
<div
218-
v-if="tagRows[0]"
222+
v-if="latestTagRow"
219223
class="border-y sm:rounded-lg sm:border border-accent/40 bg-accent/5 px-5 py-4 relative flex items-center justify-between gap-4 hover:bg-accent/8 transition-colors"
220224
>
221225
<!-- Left: tags + version -->
222226
<div>
223227
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
224228
<span class="text-3xs font-bold uppercase tracking-widest text-accent">latest</span>
225229
<span
226-
v-for="tag in tagRows[0].tags.filter(t => t !== 'latest')"
230+
v-for="tag in latestTagRow!.tags.filter(t => t !== 'latest')"
227231
:key="tag"
228232
class="text-3xs font-semibold uppercase tracking-wide text-fg-subtle"
229233
>{{ tag }}</span
230234
>
231235
</div>
232236
<LinkBase
233-
:to="packageRoute(packageName, tagRows[0].version)"
237+
:to="packageRoute(packageName, latestTagRow!.version)"
234238
class="text-2xl font-semibold tracking-tight after:absolute after:inset-0 after:content-['']"
235239
dir="ltr"
236-
>{{ tagRows[0].version }}</LinkBase
240+
>{{ latestTagRow!.version }}</LinkBase
237241
>
238242
</div>
239243
<!-- Right: date + provenance -->
240244
<div class="flex flex-col items-end gap-1.5 shrink-0 relative z-10">
241245
<ProvenanceBadge
242-
v-if="fullVersionMap?.get(tagRows[0].version)?.hasProvenance"
246+
v-if="fullVersionMap?.get(latestTagRow!.version)?.hasProvenance"
243247
:package-name="packageName"
244-
:version="tagRows[0].version"
248+
:version="latestTagRow!.version"
245249
compact
246250
:linked="false"
247251
/>
248252
<DateTime
249-
v-if="getVersionTime(tagRows[0].version)"
250-
:datetime="getVersionTime(tagRows[0].version)!"
253+
v-if="getVersionTime(latestTagRow!.version)"
254+
:datetime="getVersionTime(latestTagRow!.version)!"
251255
class="text-xs text-fg-subtle"
252256
year="numeric"
253257
month="short"
@@ -258,11 +262,11 @@ const flatItems = computed<FlatItem[]>(() => {
258262

259263
<!-- Other tags — compact list (hidden when only latest exists) -->
260264
<div
261-
v-if="tagRows.length > 1"
265+
v-if="otherTagRows.length > 0"
262266
class="border-y sm:rounded-lg sm:border border-border sm:overflow-hidden"
263267
>
264268
<div
265-
v-for="row in tagRows.slice(1)"
269+
v-for="row in otherTagRows"
266270
:key="row.id"
267271
class="flex items-center gap-4 px-4 py-2.5 border-b border-border last:border-0 hover:bg-bg-subtle transition-colors relative"
268272
>

app/utils/versions.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,73 @@ export function getPrereleaseChannel(version: string): string {
5151
return match ? match[1]!.toLowerCase() : ''
5252
}
5353

54+
/**
55+
* Priority order for well-known dist-tags.
56+
* Lower number = higher priority in display order.
57+
* Unknown tags fall back to Infinity and are sorted by publish date descending.
58+
*/
59+
export const TAG_PRIORITY: Record<string, number> = {
60+
latest: 0,
61+
stable: 1,
62+
rc: 2,
63+
beta: 3,
64+
next: 4,
65+
alpha: 5,
66+
canary: 6,
67+
nightly: 7,
68+
experimental: 8,
69+
legacy: 9,
70+
}
71+
72+
/**
73+
* Get the display priority for a dist-tag.
74+
* Uses fuzzy matching so e.g. "v2-legacy" matches "legacy".
75+
* @param tag - The tag name (e.g., "beta", "v2-legacy")
76+
* @returns Numeric priority (lower = higher priority); Infinity for unknown tags
77+
*/
78+
export function getTagPriority(tag: string | undefined): number {
79+
if (!tag) return Infinity
80+
for (const [key, priority] of Object.entries(TAG_PRIORITY)) {
81+
if (tag.toLowerCase().includes(key)) return priority
82+
}
83+
return Infinity
84+
}
85+
86+
/**
87+
* Compare two tagged version rows for display ordering.
88+
* Sorts by minimum tag priority first; falls back to publish date descending.
89+
* @param rowA - First row
90+
* @param rowB - Second row
91+
* @param versionTimes - Map of version string to ISO publish time
92+
* @returns Negative/zero/positive comparator value
93+
*/
94+
export function compareTagRows(
95+
rowA: TaggedVersionRow,
96+
rowB: TaggedVersionRow,
97+
versionTimes: Record<string, string>,
98+
): number {
99+
const priorityA = Math.min(...rowA.tags.map(getTagPriority))
100+
const priorityB = Math.min(...rowB.tags.map(getTagPriority))
101+
if (priorityA !== priorityB) return priorityA - priorityB
102+
const timeA = versionTimes[rowA.version] ?? ''
103+
const timeB = versionTimes[rowB.version] ?? ''
104+
return timeB.localeCompare(timeA)
105+
}
106+
107+
/**
108+
* Compare two version group keys for display ordering.
109+
* Sorts by major descending, then by minor descending for 0.x groups.
110+
* @param a - Group key (e.g. "1", "0.9")
111+
* @param b - Group key (e.g. "2", "0.10")
112+
* @returns Negative/zero/positive comparator value
113+
*/
114+
export function compareVersionGroupKeys(a: string, b: string): number {
115+
const [majorA, minorA] = a.split('.').map(Number)
116+
const [majorB, minorB] = b.split('.').map(Number)
117+
if (majorA !== majorB) return (majorB ?? 0) - (majorA ?? 0)
118+
return (minorB ?? -1) - (minorA ?? -1)
119+
}
120+
54121
/**
55122
* Sort tags with 'latest' first, then alphabetically
56123
* @param tags - Array of tag names

test/unit/app/utils/versions.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
22
import {
33
buildTaggedVersionRows,
44
buildVersionToTagsMap,
5+
compareTagRows,
6+
compareVersionGroupKeys,
57
filterExcludedTags,
68
filterVersions,
79
getPrereleaseChannel,
@@ -423,6 +425,105 @@ describe('isSameVersionGroup', () => {
423425
})
424426
})
425427

428+
describe('compareTagRows', () => {
429+
function row(version: string, tags: string[]) {
430+
return { id: `version:${version}`, primaryTag: tags[0]!, tags, version }
431+
}
432+
433+
it('sorts by tag priority ascending (rc before beta)', () => {
434+
const rc = row('2.0.0-rc.1', ['rc'])
435+
const beta = row('2.0.0-beta.1', ['beta'])
436+
expect(compareTagRows(rc, beta, {})).toBeLessThan(0)
437+
expect(compareTagRows(beta, rc, {})).toBeGreaterThan(0)
438+
})
439+
440+
it('sorts by tag priority ascending (beta before alpha)', () => {
441+
const beta = row('2.0.0-beta.1', ['beta'])
442+
const alpha = row('2.0.0-alpha.1', ['alpha'])
443+
expect(compareTagRows(beta, alpha, {})).toBeLessThan(0)
444+
})
445+
446+
it('falls back to publish date descending when priorities are equal', () => {
447+
const newer = row('1.1.0', ['legacy'])
448+
const older = row('1.0.0', ['legacy'])
449+
const times = { '1.1.0': '2024-06-01T00:00:00.000Z', '1.0.0': '2024-01-01T00:00:00.000Z' }
450+
expect(compareTagRows(newer, older, times)).toBeLessThan(0)
451+
expect(compareTagRows(older, newer, times)).toBeGreaterThan(0)
452+
})
453+
454+
it('returns 0 for equal priority and equal publish time', () => {
455+
const a = row('1.0.0', ['legacy'])
456+
const b = row('1.0.1', ['legacy'])
457+
const times = { '1.0.0': '2024-01-01T00:00:00.000Z', '1.0.1': '2024-01-01T00:00:00.000Z' }
458+
expect(compareTagRows(a, b, times)).toBe(0)
459+
})
460+
461+
it('uses minimum tag priority for multi-tag rows', () => {
462+
// Row with ['rc', 'next'] has min priority of rc (2)
463+
// Row with ['beta'] has priority 3 — so rc-row should sort first
464+
const rcAndNext = row('3.0.0-rc.1', ['rc', 'next'])
465+
const beta = row('3.0.0-beta.1', ['beta'])
466+
expect(compareTagRows(rcAndNext, beta, {})).toBeLessThan(0)
467+
})
468+
469+
it('sorts unknown tags after known priority tags', () => {
470+
const known = row('2.0.0-alpha.1', ['alpha'])
471+
const unknown = row('2.0.0-custom.1', ['custom-tag'])
472+
expect(compareTagRows(known, unknown, {})).toBeLessThan(0)
473+
})
474+
475+
it('sorts unknown tags by publish date descending', () => {
476+
const newer = row('2.0.0', ['v2-custom'])
477+
const older = row('1.0.0', ['v1-custom'])
478+
const times = { '2.0.0': '2025-01-01T00:00:00.000Z', '1.0.0': '2024-01-01T00:00:00.000Z' }
479+
expect(compareTagRows(newer, older, times)).toBeLessThan(0)
480+
})
481+
482+
it('treats missing publish time as empty string (sorts last among same-priority rows)', () => {
483+
const withTime = row('1.1.0', ['legacy'])
484+
const withoutTime = row('1.0.0', ['legacy'])
485+
const times = { '1.1.0': '2024-06-01T00:00:00.000Z' }
486+
expect(compareTagRows(withTime, withoutTime, times)).toBeLessThan(0)
487+
})
488+
})
489+
490+
describe('compareVersionGroupKeys', () => {
491+
it('sorts higher major before lower major', () => {
492+
expect(compareVersionGroupKeys('2', '1')).toBeLessThan(0)
493+
expect(compareVersionGroupKeys('1', '2')).toBeGreaterThan(0)
494+
})
495+
496+
it('returns 0 for equal keys', () => {
497+
expect(compareVersionGroupKeys('3', '3')).toBe(0)
498+
expect(compareVersionGroupKeys('0.9', '0.9')).toBe(0)
499+
})
500+
501+
it('sorts higher minor before lower minor for 0.x groups', () => {
502+
expect(compareVersionGroupKeys('0.10', '0.9')).toBeLessThan(0)
503+
expect(compareVersionGroupKeys('0.9', '0.10')).toBeGreaterThan(0)
504+
})
505+
506+
it('sorts non-0.x keys (no minor) before 0.x keys with same major', () => {
507+
// major-only key "0" has no minor (undefined → -1), so "0.1" sorts before "0"
508+
expect(compareVersionGroupKeys('0.1', '0')).toBeLessThan(0)
509+
})
510+
511+
it('sorts major-version groups in descending order when used with Array.sort', () => {
512+
const keys = ['1', '3', '2', '10']
513+
expect(keys.sort(compareVersionGroupKeys)).toEqual(['10', '3', '2', '1'])
514+
})
515+
516+
it('sorts 0.x groups in descending minor order when used with Array.sort', () => {
517+
const keys = ['0.1', '0.10', '0.9', '0.2']
518+
expect(keys.sort(compareVersionGroupKeys)).toEqual(['0.10', '0.9', '0.2', '0.1'])
519+
})
520+
521+
it('interleaves major and 0.x groups correctly', () => {
522+
const keys = ['0.9', '1', '0.10', '2']
523+
expect(keys.sort(compareVersionGroupKeys)).toEqual(['2', '1', '0.10', '0.9'])
524+
})
525+
})
526+
426527
describe('filterVersions', () => {
427528
const versions = ['1.0.0', '1.1.0', '1.5.3', '2.0.0', '2.1.0', '3.0.0-beta.1']
428529

0 commit comments

Comments
 (0)