-
-
Notifications
You must be signed in to change notification settings - Fork 365
feat: version history page display download count #2178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -49,6 +58,47 @@ 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)}`, | ||
| { | ||
| key: () => `npmjs-versions:${packageName.value}`, | ||
| deep: false, | ||
| default: () => ({ versions: [] }), | ||
| getCachedData(key, nuxtApp) { | ||
| return nuxtApp.static.data[key] ?? nuxtApp.payload.data[key] | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| const numberFormatter = useNumberFormatter() | ||
| 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 | ||
| } | ||
|
|
||
| return hasValue ? total : undefined | ||
| } | ||
|
|
||
| // ─── Phase 2: full metadata (loaded on first group expand) ──────────────────── | ||
| // Fetches deprecated status, provenance, and exact times needed for version rows. | ||
|
|
||
|
|
@@ -237,10 +287,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="$t('package.downloads.title')" | ||
| dir="ltr" | ||
| > | ||
| {{ numberFormatter.format(getVersionDownloads(latestTagRow!.version)!) }} | ||
| </div> | ||
|
Comment on lines
+294
to
+301
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: No, on a non-interactive HTML span or div (default role=generic, nameFrom=contents), aria-label does not override or hide the element's visible text in the accessibility tree. The visible text content is exposed as the accessible name, and aria-label is typically ignored or not used for naming such elements per the Accessible Name and Description Computation spec. Screen readers will read the visible text when navigating to or through the element. The recommended accessible pattern for a numeric download count that needs a spoken label (e.g., "5,247 downloads") is to use a span or div with role="status" (implicit aria-live=polite) containing both the visible number and descriptive text, e.g.: 5,247 downloads This announces the full phrase to screen readers when the count updates dynamically. If the number updates frequently, ensure the live region exists before updates and consider aria-atomic=true. For static counts, visible text alone suffices, but role="status" enhances announcement if needed. Avoid aria-label to prevent mismatches with visible content (WCAG 2.5.3 Label in Name). Citations:
Remove The Also applies to: 351–358, 445–452, 518–525, 564–571 |
||
| <div class="flex flex-col items-end gap-1.5 shrink-0 relative z-10"> | ||
| <ProvenanceBadge | ||
| v-if="fullVersionMap?.get(latestTagRow!.version)?.hasProvenance" | ||
|
|
@@ -286,10 +344,18 @@ 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="$t('package.downloads.title')" | ||
| dir="ltr" | ||
| > | ||
| {{ numberFormatter.format(getVersionDownloads(row.version)!) }} | ||
| </span> | ||
| <DateTime | ||
| v-if="getVersionTime(row.version)" | ||
| :datetime="getVersionTime(row.version)!" | ||
|
|
@@ -373,10 +439,20 @@ 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="$t('package.downloads.title')" | ||
| 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" | ||
| year="numeric" | ||
|
|
@@ -413,7 +489,7 @@ const flatItems = computed<FlatItem[]>(() => { | |
| " | ||
| dir="ltr" | ||
| > | ||
| {{ item.version }} | ||
| v{{ item.version }} | ||
| </LinkBase> | ||
| <div | ||
| v-if="versionToTagsMap.get(item.version)?.length" | ||
|
|
@@ -438,7 +514,15 @@ 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 w-50 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="$t('package.downloads.title')" | ||
| dir="ltr" | ||
| > | ||
| {{ numberFormatter.format(getVersionDownloads(item.version)!) }} | ||
| </span> | ||
| <!-- Metadata: date + provenance --> | ||
| <DateTime | ||
| v-if="getVersionTime(item.version)" | ||
|
|
@@ -477,10 +561,20 @@ 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="$t('package.downloads.title')" | ||
| 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" | ||
| year="numeric" | ||
|
|
||
| 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}` | ||
| }, | ||
| }, | ||
| ) |
| 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, | ||
| })) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1314
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 654
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 3628
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 442
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 421
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1201
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 466
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1014
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1073
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 513
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1338
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 88
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 43
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 230
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 241
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 1054
🏁 Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 81
🌐 Web query:
WCAG 2.1 aria-label on numeric content div span accessibility💡 Result:
Under WCAG 2.1, using aria-label on a div or span containing numeric content is appropriate only when the element has an explicit ARIA role that requires an accessible name (e.g., role="button", role="slider", role="meter") and lacks visible labeling text. For non-interactive elements like plain div/span displaying static numbers (e.g., scores, prices), aria-label is not recommended and may be ignored by assistive technologies, as these elements do not support naming by default. Key guidelines: - Prioritize native HTML semantics (e.g., use , , headings) for structure. - For interactive numeric controls (e.g., inputs type="number"), use aria-label or aria-labelledby if no visible label (ARIA14, example with phone number inputs). - For ranges/sliders with numeric values, use aria-valuenow, aria-valuemin/max, and aria-valuetext for context (
@19). - Static numeric content in div/span needs contextual surrounding text for screen readers; avoid aria-label on non-role elements (@4,@6). - Prefer aria-labelledby referencing existing visible text over aria-label (@1,@8,@13). Example for interactive numeric span (e.g., editable value): 5 For static display:@2).Citations:
Accessibility issue with numeric content lacking semantic markup.
The
aria-labelattribute on plain<span>and<div>elements containing only numeric content (download counts at lines 297, 354, 448, 521, 567) does not meet WCAG 2.1 guidelines. Screen readers typically ignorearia-labelon elements without an explicit ARIA role or semantic meaning. Add contextual surrounding text visible to screen readers, usearia-labelledbyto reference an existing visible label, or consider<strong>and other semantic elements for better accessibility.Additionally, this fetch now depends on
server/api/registry/npmjs-versions/[...pkg].get.ts. WhileencodePackageName()correctly handles scoped packages, no E2E tests verify the complete integration with@scope/namepackages. Before shipping, confirm the download counts render correctly for scoped packages in the versions view.