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
36 changes: 20 additions & 16 deletions app/pages/package/[[org]]/[name]/versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { compare, validRange } from 'semver'
import {
buildVersionToTagsMap,
buildTaggedVersionRows,
compareTagRows,
compareVersionGroupKeys,
filterVersions,
getVersionGroupKey,
getVersionGroupLabel,
Expand Down Expand Up @@ -64,7 +66,14 @@ async function ensureFullDataLoaded() {
// ─── Derived data ─────────────────────────────────────────────────────────────

const versionToTagsMap = computed(() => buildVersionToTagsMap(distTags.value))

const tagRows = computed(() => buildTaggedVersionRows(distTags.value))
const latestTagRow = computed(() => tagRows.value.find(r => r.tags.includes('latest')) ?? null)
const otherTagRows = computed(() =>
tagRows.value
.filter(r => !r.tags.includes('latest'))
.sort((rowA, rowB) => compareTagRows(rowA, rowB, versionTimes.value)),
)

function getVersionTime(version: string): string | undefined {
return versionTimes.value[version]
Expand All @@ -84,12 +93,7 @@ const versionGroups = computed(() => {
}

return Array.from(byKey.keys())
.sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number)
const [bMajor, bMinor] = b.split('.').map(Number)
if (aMajor !== bMajor) return (bMajor ?? 0) - (aMajor ?? 0)
return (bMinor ?? -1) - (aMinor ?? -1)
})
.sort(compareVersionGroupKeys)
.map(groupKey => ({
groupKey,
label: getVersionGroupLabel(groupKey),
Expand Down Expand Up @@ -215,39 +219,39 @@ const flatItems = computed<FlatItem[]>(() => {

<!-- Latest — featured card -->
<div
v-if="tagRows[0]"
v-if="latestTagRow"
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"
>
<!-- Left: tags + version -->
<div>
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
<span class="text-3xs font-bold uppercase tracking-widest text-accent">latest</span>
<span
v-for="tag in tagRows[0].tags.filter(t => t !== 'latest')"
v-for="tag in latestTagRow!.tags.filter(t => t !== 'latest')"
:key="tag"
class="text-3xs font-semibold uppercase tracking-wide text-fg-subtle"
>{{ tag }}</span
>
</div>
<LinkBase
:to="packageRoute(packageName, tagRows[0].version)"
:to="packageRoute(packageName, latestTagRow!.version)"
class="text-2xl font-semibold tracking-tight after:absolute after:inset-0 after:content-['']"
dir="ltr"
>{{ tagRows[0].version }}</LinkBase
>{{ latestTagRow!.version }}</LinkBase
>
</div>
<!-- Right: date + provenance -->
<div class="flex flex-col items-end gap-1.5 shrink-0 relative z-10">
<ProvenanceBadge
v-if="fullVersionMap?.get(tagRows[0].version)?.hasProvenance"
v-if="fullVersionMap?.get(latestTagRow!.version)?.hasProvenance"
:package-name="packageName"
:version="tagRows[0].version"
:version="latestTagRow!.version"
compact
:linked="false"
/>
<DateTime
v-if="getVersionTime(tagRows[0].version)"
:datetime="getVersionTime(tagRows[0].version)!"
v-if="getVersionTime(latestTagRow!.version)"
:datetime="getVersionTime(latestTagRow!.version)!"
class="text-xs text-fg-subtle"
year="numeric"
month="short"
Expand All @@ -258,11 +262,11 @@ const flatItems = computed<FlatItem[]>(() => {

<!-- Other tags — compact list (hidden when only latest exists) -->
<div
v-if="tagRows.length > 1"
v-if="otherTagRows.length > 0"
class="border-y sm:rounded-lg sm:border border-border sm:overflow-hidden"
>
<div
v-for="row in tagRows.slice(1)"
v-for="row in otherTagRows"
:key="row.id"
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"
>
Expand Down
67 changes: 67 additions & 0 deletions app/utils/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,73 @@ export function getPrereleaseChannel(version: string): string {
return match ? match[1]!.toLowerCase() : ''
}

/**
* Priority order for well-known dist-tags.
* Lower number = higher priority in display order.
* Unknown tags fall back to Infinity and are sorted by publish date descending.
*/
export const TAG_PRIORITY: Record<string, number> = {
latest: 0,
stable: 1,
rc: 2,
beta: 3,
next: 4,
alpha: 5,
canary: 6,
nightly: 7,
experimental: 8,
legacy: 9,
}

/**
* Get the display priority for a dist-tag.
* Uses fuzzy matching so e.g. "v2-legacy" matches "legacy".
* @param tag - The tag name (e.g., "beta", "v2-legacy")
* @returns Numeric priority (lower = higher priority); Infinity for unknown tags
*/
export function getTagPriority(tag: string | undefined): number {
if (!tag) return Infinity
for (const [key, priority] of Object.entries(TAG_PRIORITY)) {
if (tag.toLowerCase().includes(key)) return priority
}
return Infinity
}

/**
* Compare two tagged version rows for display ordering.
* Sorts by minimum tag priority first; falls back to publish date descending.
* @param rowA - First row
* @param rowB - Second row
* @param versionTimes - Map of version string to ISO publish time
* @returns Negative/zero/positive comparator value
*/
export function compareTagRows(
rowA: TaggedVersionRow,
rowB: TaggedVersionRow,
versionTimes: Record<string, string>,
): number {
const priorityA = Math.min(...rowA.tags.map(getTagPriority))
const priorityB = Math.min(...rowB.tags.map(getTagPriority))
if (priorityA !== priorityB) return priorityA - priorityB
const timeA = versionTimes[rowA.version] ?? ''
const timeB = versionTimes[rowB.version] ?? ''
return timeB.localeCompare(timeA)
}

/**
* Compare two version group keys for display ordering.
* Sorts by major descending, then by minor descending for 0.x groups.
* @param a - Group key (e.g. "1", "0.9")
* @param b - Group key (e.g. "2", "0.10")
* @returns Negative/zero/positive comparator value
*/
export function compareVersionGroupKeys(a: string, b: string): number {
const [majorA, minorA] = a.split('.').map(Number)
const [majorB, minorB] = b.split('.').map(Number)
if (majorA !== majorB) return (majorB ?? 0) - (majorA ?? 0)
return (minorB ?? -1) - (minorA ?? -1)
}

/**
* Sort tags with 'latest' first, then alphabetically
* @param tags - Array of tag names
Expand Down
101 changes: 101 additions & 0 deletions test/unit/app/utils/versions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import {
buildTaggedVersionRows,
buildVersionToTagsMap,
compareTagRows,
compareVersionGroupKeys,
filterExcludedTags,
filterVersions,
getPrereleaseChannel,
Expand Down Expand Up @@ -423,6 +425,105 @@
})
})

describe('compareTagRows', () => {
function row(version: string, tags: string[]) {

Check warning on line 429 in test/unit/app/utils/versions.spec.ts

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

eslint-plugin-unicorn(consistent-function-scoping)

Function `row` does not capture any variables from its parent scope
return { id: `version:${version}`, primaryTag: tags[0]!, tags, version }
}

it('sorts by tag priority ascending (rc before beta)', () => {
const rc = row('2.0.0-rc.1', ['rc'])
const beta = row('2.0.0-beta.1', ['beta'])
expect(compareTagRows(rc, beta, {})).toBeLessThan(0)
expect(compareTagRows(beta, rc, {})).toBeGreaterThan(0)
})

it('sorts by tag priority ascending (beta before alpha)', () => {
const beta = row('2.0.0-beta.1', ['beta'])
const alpha = row('2.0.0-alpha.1', ['alpha'])
expect(compareTagRows(beta, alpha, {})).toBeLessThan(0)
})

it('falls back to publish date descending when priorities are equal', () => {
const newer = row('1.1.0', ['legacy'])
const older = row('1.0.0', ['legacy'])
const times = { '1.1.0': '2024-06-01T00:00:00.000Z', '1.0.0': '2024-01-01T00:00:00.000Z' }
expect(compareTagRows(newer, older, times)).toBeLessThan(0)
expect(compareTagRows(older, newer, times)).toBeGreaterThan(0)
})

it('returns 0 for equal priority and equal publish time', () => {
const a = row('1.0.0', ['legacy'])
const b = row('1.0.1', ['legacy'])
const times = { '1.0.0': '2024-01-01T00:00:00.000Z', '1.0.1': '2024-01-01T00:00:00.000Z' }
expect(compareTagRows(a, b, times)).toBe(0)
})

it('uses minimum tag priority for multi-tag rows', () => {
// Row with ['rc', 'next'] has min priority of rc (2)
// Row with ['beta'] has priority 3 — so rc-row should sort first
const rcAndNext = row('3.0.0-rc.1', ['rc', 'next'])
const beta = row('3.0.0-beta.1', ['beta'])
expect(compareTagRows(rcAndNext, beta, {})).toBeLessThan(0)
})

it('sorts unknown tags after known priority tags', () => {
const known = row('2.0.0-alpha.1', ['alpha'])
const unknown = row('2.0.0-custom.1', ['custom-tag'])
expect(compareTagRows(known, unknown, {})).toBeLessThan(0)
})

it('sorts unknown tags by publish date descending', () => {
const newer = row('2.0.0', ['v2-custom'])
const older = row('1.0.0', ['v1-custom'])
const times = { '2.0.0': '2025-01-01T00:00:00.000Z', '1.0.0': '2024-01-01T00:00:00.000Z' }
expect(compareTagRows(newer, older, times)).toBeLessThan(0)
})

it('treats missing publish time as empty string (sorts last among same-priority rows)', () => {
const withTime = row('1.1.0', ['legacy'])
const withoutTime = row('1.0.0', ['legacy'])
const times = { '1.1.0': '2024-06-01T00:00:00.000Z' }
expect(compareTagRows(withTime, withoutTime, times)).toBeLessThan(0)
})
})

describe('compareVersionGroupKeys', () => {
it('sorts higher major before lower major', () => {
expect(compareVersionGroupKeys('2', '1')).toBeLessThan(0)
expect(compareVersionGroupKeys('1', '2')).toBeGreaterThan(0)
})

it('returns 0 for equal keys', () => {
expect(compareVersionGroupKeys('3', '3')).toBe(0)
expect(compareVersionGroupKeys('0.9', '0.9')).toBe(0)
})

it('sorts higher minor before lower minor for 0.x groups', () => {
expect(compareVersionGroupKeys('0.10', '0.9')).toBeLessThan(0)
expect(compareVersionGroupKeys('0.9', '0.10')).toBeGreaterThan(0)
})

it('sorts non-0.x keys (no minor) before 0.x keys with same major', () => {
// major-only key "0" has no minor (undefined → -1), so "0.1" sorts before "0"
expect(compareVersionGroupKeys('0.1', '0')).toBeLessThan(0)
})

it('sorts major-version groups in descending order when used with Array.sort', () => {
const keys = ['1', '3', '2', '10']
expect(keys.sort(compareVersionGroupKeys)).toEqual(['10', '3', '2', '1'])
})

it('sorts 0.x groups in descending minor order when used with Array.sort', () => {
const keys = ['0.1', '0.10', '0.9', '0.2']
expect(keys.sort(compareVersionGroupKeys)).toEqual(['0.10', '0.9', '0.2', '0.1'])
})

it('interleaves major and 0.x groups correctly', () => {
const keys = ['0.9', '1', '0.10', '2']
expect(keys.sort(compareVersionGroupKeys)).toEqual(['2', '1', '0.10', '0.9'])
})
})

describe('filterVersions', () => {
const versions = ['1.0.0', '1.1.0', '1.5.3', '2.0.0', '2.1.0', '3.0.0-beta.1']

Expand Down
Loading