Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8ebefec
feat: version history page display download count
btea Mar 21, 2026
6e6bbcb
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 21, 2026
c12f507
feat: update
btea Mar 21, 2026
9923d48
feat: update
btea Mar 24, 2026
a58326f
feat: update
btea Mar 24, 2026
58a0108
style: update
btea Mar 24, 2026
9ff48d8
feat: update
btea Mar 24, 2026
b3a6436
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 24, 2026
189bef9
feat: update
btea Mar 24, 2026
e1b1005
feat: update
btea Mar 24, 2026
bfba80e
style: update
btea Mar 26, 2026
95240a9
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 26, 2026
3a58861
feat: update
btea Mar 26, 2026
333d8ae
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 26, 2026
c9dd826
feat: update
btea Mar 30, 2026
3d451f3
feat: update
btea Mar 30, 2026
47f726e
Merge branch 'main' into feat/history-versions-display-download
ghostdevv Apr 4, 2026
604836a
Merge remote-tracking branch 'origin/main' into feat/history-versions…
btea Apr 5, 2026
8af1162
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 5, 2026
6c5bdc3
feat: update
btea Apr 5, 2026
fa864fd
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 5, 2026
2642a3c
feat: update
btea Apr 6, 2026
f843c73
Merge branch 'main' into feat/history-versions-display-download
btea Apr 7, 2026
35491c0
feat: move deprecated
btea Apr 12, 2026
70af23b
feat: move provenance
btea Apr 12, 2026
1645aa5
test: update
btea Apr 12, 2026
e5040cc
test: update
btea Apr 12, 2026
1cfedb2
refactor: code
btea Apr 14, 2026
7871c06
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 14, 2026
577afc2
style: update
btea Apr 15, 2026
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
129 changes: 114 additions & 15 deletions app/pages/package/[[org]]/[name]/versions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ definePageMeta({
name: 'package-versions',
})

interface NpmWebsiteVersionDownload {
version: string
downloads: number
}

interface NpmWebsiteVersionsResponse {
versions: NpmWebsiteVersionDownload[]
}

/** Number of flat items (headers + version rows) to render statically during SSR */
const SSR_COUNT = 20

Expand Down Expand Up @@ -49,6 +58,52 @@ const distTags = computed(() => versionSummary.value?.distTags ?? {})
const versionStrings = computed(() => versionSummary.value?.versions ?? [])
const versionTimes = computed(() => versionSummary.value?.time ?? {})

const { data: npmWebsiteVersions } = useLazyFetch<NpmWebsiteVersionsResponse>(
() => `/api/registry/npmjs-versions/${encodeURIComponent(packageName.value)}`,
Comment thread
btea marked this conversation as resolved.
Outdated
{
key: () => `npmjs-versions:${packageName.value}`,
deep: false,
default: () => ({ versions: [] }),
getCachedData(key, nuxtApp) {
return nuxtApp.static.data[key] ?? nuxtApp.payload.data[key]
},
},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const numberFormatter = useNumberFormatter()
const { t } = useI18n()
const versionDownloadsMap = computed(
() =>
new Map(
(npmWebsiteVersions.value?.versions ?? []).map(({ version, downloads }) => [
version,
downloads,
]),
),
)

function getVersionDownloads(version: string): number | undefined {
return versionDownloadsMap.value.get(version)
}

function getGroupDownloads(versions: string[]): number | undefined {
let total = 0
let hasValue = false

for (const version of versions) {
const downloads = getVersionDownloads(version)
if (downloads === undefined) continue
total += downloads
hasValue = true
}
Comment thread
btea marked this conversation as resolved.

return hasValue ? total : undefined
}

function getDownloadsAriaLabel(downloads: number): string {
return `${numberFormatter.value.format(downloads)} ${t('package.downloads.title')}`
}

// ─── Phase 2: full metadata (loaded on first group expand) ────────────────────
// Fetches deprecated status, provenance, and exact times needed for version rows.

Expand Down Expand Up @@ -237,10 +292,18 @@ const flatItems = computed<FlatItem[]>(() => {
:to="packageRoute(packageName, latestTagRow!.version)"
class="text-2xl font-semibold tracking-tight after:absolute after:inset-0 after:content-['']"
dir="ltr"
>{{ latestTagRow!.version }}</LinkBase
>v{{ latestTagRow!.version }}</LinkBase
>
</div>
<!-- Right: date + provenance -->
<div
v-if="getVersionDownloads(latestTagRow!.version) !== undefined"
class="text-sm font-medium text-fg tabular-nums shrink-0"
:aria-label="getDownloadsAriaLabel(getVersionDownloads(latestTagRow!.version)!)"
dir="ltr"
>
{{ numberFormatter.format(getVersionDownloads(latestTagRow!.version)!) }}
</div>
<div class="flex flex-col items-end gap-1.5 shrink-0 relative z-10">
<ProvenanceBadge
v-if="fullVersionMap?.get(latestTagRow!.version)?.hasProvenance"
Expand All @@ -252,7 +315,7 @@ const flatItems = computed<FlatItem[]>(() => {
<DateTime
v-if="getVersionTime(latestTagRow!.version)"
:datetime="getVersionTime(latestTagRow!.version)!"
class="text-xs text-fg-subtle"
class="text-xs text-fg-subtle whitespace-nowrap"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -286,14 +349,22 @@ const flatItems = computed<FlatItem[]>(() => {
class="text-sm flex-1 min-w-0 after:absolute after:inset-0 after:content-['']"
dir="ltr"
>
{{ row.version }}
v{{ row.version }}
</LinkBase>

<!-- Date -->
<span
v-if="getVersionDownloads(row.version) !== undefined"
class="text-xs text-fg-muted shrink-0 tabular-nums w-24 text-end"
:aria-label="getDownloadsAriaLabel(getVersionDownloads(row.version)!)"
dir="ltr"
>
{{ numberFormatter.format(getVersionDownloads(row.version)!) }}
</span>
<DateTime
v-if="getVersionTime(row.version)"
:datetime="getVersionTime(row.version)!"
class="text-xs text-fg-subtle shrink-0 hidden sm:block"
class="text-xs text-fg-subtle shrink-0 hidden sm:block whitespace-nowrap"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -373,12 +444,22 @@ const flatItems = computed<FlatItem[]>(() => {
</span>
<span class="text-sm font-medium">{{ item.label }}</span>
<span class="text-xs text-fg-subtle">({{ item.versions.length }})</span>
<span class="ms-auto flex items-center gap-3 shrink-0">
<span class="text-xs text-fg-muted" dir="ltr">{{ item.versions[0] }}</span>
<span v-if="item.versions[0]" class="text-xs text-fg-muted" dir="ltr"
>v{{ item.versions[0] }}</span
>
<span
v-if="getGroupDownloads(item.versions) !== undefined"
class="ms-auto text-xs text-fg-muted tabular-nums w-24 text-end"
:aria-label="getDownloadsAriaLabel(getGroupDownloads(item.versions)!)"
dir="ltr"
>
{{ numberFormatter.format(getGroupDownloads(item.versions)!) }}
</span>
<span class="flex items-center gap-3 shrink-0">
<DateTime
v-if="getVersionTime(item.versions[0])"
v-if="item.versions[0] && getVersionTime(item.versions[0])"
:datetime="getVersionTime(item.versions[0])!"
class="text-xs text-fg-subtle hidden sm:block"
class="text-xs text-fg-subtle hidden sm:block whitespace-nowrap"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -413,7 +494,7 @@ const flatItems = computed<FlatItem[]>(() => {
"
dir="ltr"
>
{{ item.version }}
v{{ item.version }}
</LinkBase>
<div
v-if="versionToTagsMap.get(item.version)?.length"
Expand All @@ -438,12 +519,20 @@ const flatItems = computed<FlatItem[]>(() => {
</div>

<!-- Right side -->
<div class="flex items-center gap-2 shrink-0 relative z-10">
<div class="flex items-center gap-2 shrink-0 relative z-10 justify-end">
<span
v-if="getVersionDownloads(item.version) !== undefined"
class="text-xs text-fg-muted tabular-nums w-24 text-end shrink-0"
:aria-label="getDownloadsAriaLabel(getVersionDownloads(item.version)!)"
dir="ltr"
>
{{ numberFormatter.format(getVersionDownloads(item.version)!) }}
</span>
<!-- Metadata: date + provenance -->
<DateTime
v-if="getVersionTime(item.version)"
:datetime="getVersionTime(item.version)!"
class="text-xs text-fg-subtle hidden sm:block"
class="text-xs text-fg-subtle hidden sm:block whitespace-nowrap"
year="numeric"
month="short"
day="numeric"
Expand Down Expand Up @@ -477,12 +566,22 @@ const flatItems = computed<FlatItem[]>(() => {
</span>
<span class="text-sm font-medium">{{ item.label }}</span>
<span class="text-xs text-fg-subtle">({{ item.versions.length }})</span>
<span class="ms-auto flex items-center gap-3 shrink-0">
<span class="text-xs text-fg-muted" dir="ltr">{{ item.versions[0] }}</span>
<span
v-if="getGroupDownloads(item.versions) !== undefined"
class="ms-auto text-xs text-fg-muted tabular-nums w-24 text-end"
:aria-label="getDownloadsAriaLabel(getGroupDownloads(item.versions)!)"
dir="ltr"
>
{{ numberFormatter.format(getGroupDownloads(item.versions)!) }}
</span>
<span class="flex items-center gap-3 shrink-0">
<span v-if="item.versions[0]" class="text-xs text-fg-muted" dir="ltr"
>v{{ item.versions[0] }}</span
>
<DateTime
v-if="getVersionTime(item.versions[0] ?? '')"
v-if="item.versions[0] && getVersionTime(item.versions[0])"
:datetime="getVersionTime(item.versions[0] ?? '')!"
class="text-xs text-fg-subtle hidden sm:block"
class="text-xs text-fg-subtle hidden sm:block whitespace-nowrap"
year="numeric"
month="short"
day="numeric"
Expand Down
43 changes: 43 additions & 0 deletions server/api/registry/npmjs-versions/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants'
import { fetchNpmVersionDownloadsFromApi } from '#server/utils/npm-website-versions'

export default defineCachedEventHandler(
async event => {
const pkgParam = getRouterParam(event, 'pkg')
if (!pkgParam) {
throw createError({ statusCode: 404, message: 'Package name is required' })
}

let packageName: string
try {
packageName = decodeURIComponent(pkgParam)
} catch {
throw createError({
statusCode: 400,
message: 'Invalid package name',
})
}

try {
const versions = await fetchNpmVersionDownloadsFromApi(packageName)

return {
packageName,
versions,
}
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: 'Failed to fetch version download data from npm API',
})
}
},
{
maxAge: CACHE_MAX_AGE_ONE_HOUR,
swr: true,
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
return `npmjs-versions:v2:${pkg}`
},
},
)
37 changes: 37 additions & 0 deletions server/utils/npm-website-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface NpmWebsiteVersionDownload {
version: string
downloads: number
}

interface NpmApiVersionDownloadsResponse {
downloads: Record<string, number>
}

export async function fetchNpmVersionDownloadsFromApi(
packageName: string,
): Promise<NpmWebsiteVersionDownload[]> {
const encodedName = encodePackageName(packageName)

const versionsResponse = await fetch(`https://api.npmjs.org/versions/${encodedName}/last-week`)

if (!versionsResponse.ok) {
if (versionsResponse.status === 404) {
throw createError({
statusCode: 404,
message: 'Package not found',
})
}

throw createError({
statusCode: 502,
message: 'Failed to fetch version download data from npm API',
})
}

const versionsData = (await versionsResponse.json()) as NpmApiVersionDownloadsResponse

return Object.entries(versionsData.downloads).map(([version, downloads]) => ({
version,
downloads,
}))
}
51 changes: 51 additions & 0 deletions test/unit/server/utils/npm-website-versions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createError } from 'h3'

vi.stubGlobal('encodePackageName', (name: string) => {
if (name.startsWith('@')) return `@${encodeURIComponent(name.slice(1))}`
return encodeURIComponent(name)
})
vi.stubGlobal('createError', createError)

const { fetchNpmVersionDownloadsFromApi } = await import('#server/utils/npm-website-versions')

afterEach(() => {
vi.restoreAllMocks()
Comment thread
btea marked this conversation as resolved.
})

describe('fetchNpmVersionDownloadsFromApi', () => {
it('encodes scoped package names in npm API request URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
downloads: {
'1.0.0': 123,
},
}),
})
vi.stubGlobal('fetch', fetchMock)

const result = await fetchNpmVersionDownloadsFromApi('@nuxt/kit')

expect(fetchMock).toHaveBeenCalledWith('https://api.npmjs.org/versions/@nuxt%2Fkit/last-week')
expect(result).toEqual([
{
version: '1.0.0',
downloads: 123,
},
])
})

it('throws a not-found error when npm API returns 404', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 404,
})
vi.stubGlobal('fetch', fetchMock)

await expect(fetchNpmVersionDownloadsFromApi('missing-package')).rejects.toMatchObject({
statusCode: 404,
message: 'Package not found',
})
})
})
Loading