From 9fb086fa0ef8a59e53a32aa3ba6bc382ed3449e4 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Mon, 16 Mar 2026 23:01:37 +0800 Subject: [PATCH 1/5] fix: fix tag issue and sort tagged versions --- app/pages/package/[[org]]/[name]/versions.vue | 34 +++++++++++++------ app/utils/versions.ts | 31 +++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue index 0341caef78..e5220cffbf 100644 --- a/app/pages/package/[[org]]/[name]/versions.vue +++ b/app/pages/package/[[org]]/[name]/versions.vue @@ -8,6 +8,7 @@ import { filterVersions, getVersionGroupKey, getVersionGroupLabel, + getTagPriority, } from '~/utils/versions' import { fetchAllPackageVersions } from '~/utils/npm/api' @@ -65,6 +66,19 @@ async function ensureFullDataLoaded() { 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((a, b) => { + const pa = getTagPriority(a.primaryTag) + const pb = getTagPriority(b.primaryTag) + if (pa !== pb) return pa - pb + const ta = versionTimes.value[a.version] ?? '' + const tb = versionTimes.value[b.version] ?? '' + return tb.localeCompare(ta) + }), +) function getVersionTime(version: string): string | undefined { return versionTimes.value[version] @@ -215,7 +229,7 @@ const flatItems = computed(() => {
@@ -223,31 +237,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..c3569e6717 100644 --- a/app/utils/versions.ts +++ b/app/utils/versions.ts @@ -51,6 +51,37 @@ 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 = { + stable: 0, + rc: 1, + beta: 2, + next: 3, + alpha: 4, + canary: 5, + nightly: 6, + experimental: 7, + legacy: 8, +} + +/** + * 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 +} + /** * Sort tags with 'latest' first, then alphabetically * @param tags - Array of tag names From e9155ff4097af533aeee8aafaa71a327664a816e Mon Sep 17 00:00:00 2001 From: Atriiy Date: Tue, 17 Mar 2026 00:18:28 +0800 Subject: [PATCH 2/5] refactor: include 'latest' in TAG_PRIORITY --- app/pages/package/[[org]]/[name]/versions.vue | 1 + app/utils/versions.ts | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue index e5220cffbf..29e9577089 100644 --- a/app/pages/package/[[org]]/[name]/versions.vue +++ b/app/pages/package/[[org]]/[name]/versions.vue @@ -65,6 +65,7 @@ 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(() => diff --git a/app/utils/versions.ts b/app/utils/versions.ts index c3569e6717..e662297cbc 100644 --- a/app/utils/versions.ts +++ b/app/utils/versions.ts @@ -57,15 +57,16 @@ export function getPrereleaseChannel(version: string): string { * Unknown tags fall back to Infinity and are sorted by publish date descending. */ export const TAG_PRIORITY: Record = { - stable: 0, - rc: 1, - beta: 2, - next: 3, - alpha: 4, - canary: 5, - nightly: 6, - experimental: 7, - legacy: 8, + latest: 0, + stable: 1, + rc: 2, + beta: 3, + next: 4, + alpha: 5, + canary: 6, + nightly: 7, + experimental: 8, + legacy: 9, } /** From 2706e9891abf50482fc5cb640127744d5ef2a094 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Tue, 17 Mar 2026 09:04:27 +0800 Subject: [PATCH 3/5] refactor: use readable variable name --- app/pages/package/[[org]]/[name]/versions.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue index 29e9577089..75241ad6e2 100644 --- a/app/pages/package/[[org]]/[name]/versions.vue +++ b/app/pages/package/[[org]]/[name]/versions.vue @@ -71,13 +71,13 @@ const latestTagRow = computed(() => tagRows.value.find(r => r.tags.includes('lat const otherTagRows = computed(() => tagRows.value .filter(r => !r.tags.includes('latest')) - .sort((a, b) => { - const pa = getTagPriority(a.primaryTag) - const pb = getTagPriority(b.primaryTag) - if (pa !== pb) return pa - pb - const ta = versionTimes.value[a.version] ?? '' - const tb = versionTimes.value[b.version] ?? '' - return tb.localeCompare(ta) + .sort((rowA, rowB) => { + const priorityA = getTagPriority(rowA.primaryTag) + const priorityB = getTagPriority(rowB.primaryTag) + if (priorityA !== priorityB) return priorityA - priorityB + const timeA = versionTimes.value[rowA.version] ?? '' + const timeB = versionTimes.value[rowB.version] ?? '' + return timeB.localeCompare(timeA) }), ) From 69dcd003ac929c7dec7940a5f4ff181f8748fad1 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Tue, 17 Mar 2026 09:57:13 +0800 Subject: [PATCH 4/5] refactor: optimize sorting logic --- app/pages/package/[[org]]/[name]/versions.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue index 75241ad6e2..052c9d7755 100644 --- a/app/pages/package/[[org]]/[name]/versions.vue +++ b/app/pages/package/[[org]]/[name]/versions.vue @@ -72,8 +72,8 @@ const otherTagRows = computed(() => tagRows.value .filter(r => !r.tags.includes('latest')) .sort((rowA, rowB) => { - const priorityA = getTagPriority(rowA.primaryTag) - const priorityB = getTagPriority(rowB.primaryTag) + 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.value[rowA.version] ?? '' const timeB = versionTimes.value[rowB.version] ?? '' From 279960765568fbbf03f1ed2cc1c333432f3e7118 Mon Sep 17 00:00:00 2001 From: Atriiy Date: Thu, 19 Mar 2026 16:44:57 +0800 Subject: [PATCH 5/5] refactor: move sorting lambda to utils and add unit tests --- app/pages/package/[[org]]/[name]/versions.vue | 19 +--- app/utils/versions.ts | 35 ++++++ test/unit/app/utils/versions.spec.ts | 101 ++++++++++++++++++ 3 files changed, 140 insertions(+), 15 deletions(-) diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue index 052c9d7755..8a3b9c5070 100644 --- a/app/pages/package/[[org]]/[name]/versions.vue +++ b/app/pages/package/[[org]]/[name]/versions.vue @@ -5,10 +5,11 @@ import { compare, validRange } from 'semver' import { buildVersionToTagsMap, buildTaggedVersionRows, + compareTagRows, + compareVersionGroupKeys, filterVersions, getVersionGroupKey, getVersionGroupLabel, - getTagPriority, } from '~/utils/versions' import { fetchAllPackageVersions } from '~/utils/npm/api' @@ -71,14 +72,7 @@ const latestTagRow = computed(() => tagRows.value.find(r => r.tags.includes('lat const otherTagRows = computed(() => tagRows.value .filter(r => !r.tags.includes('latest')) - .sort((rowA, rowB) => { - 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.value[rowA.version] ?? '' - const timeB = versionTimes.value[rowB.version] ?? '' - return timeB.localeCompare(timeA) - }), + .sort((rowA, rowB) => compareTagRows(rowA, rowB, versionTimes.value)), ) function getVersionTime(version: string): string | undefined { @@ -99,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), diff --git a/app/utils/versions.ts b/app/utils/versions.ts index e662297cbc..e2aa8741a7 100644 --- a/app/utils/versions.ts +++ b/app/utils/versions.ts @@ -83,6 +83,41 @@ export function getTagPriority(tag: string | undefined): number { 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']