diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue index 0341caef78..8a3b9c5070 100644 --- a/app/pages/package/[[org]]/[name]/versions.vue +++ b/app/pages/package/[[org]]/[name]/versions.vue @@ -5,6 +5,8 @@ import { compare, validRange } from 'semver' import { buildVersionToTagsMap, buildTaggedVersionRows, + compareTagRows, + compareVersionGroupKeys, filterVersions, getVersionGroupKey, getVersionGroupLabel, @@ -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] @@ -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), @@ -215,7 +219,7 @@ const flatItems = computed(() => {
@@ -223,31 +227,31 @@ const flatItems = computed(() => {
latest {{ tag }}
{{ tagRows[0].version }}{{ latestTagRow!.version }}
(() => {
diff --git a/app/utils/versions.ts b/app/utils/versions.ts index 88f3c4bc33..e2aa8741a7 100644 --- a/app/utils/versions.ts +++ b/app/utils/versions.ts @@ -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 = { + 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, +): 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 diff --git a/test/unit/app/utils/versions.spec.ts b/test/unit/app/utils/versions.spec.ts index b4851abed1..2108d4f265 100644 --- a/test/unit/app/utils/versions.spec.ts +++ b/test/unit/app/utils/versions.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { buildTaggedVersionRows, buildVersionToTagsMap, + compareTagRows, + compareVersionGroupKeys, filterExcludedTags, filterVersions, getPrereleaseChannel, @@ -423,6 +425,105 @@ describe('isSameVersionGroup', () => { }) }) +describe('compareTagRows', () => { + function row(version: string, tags: string[]) { + 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']