Skip to content
Open
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
114 changes: 104 additions & 10 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,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]
},
},
)
Comment on lines +61 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

route_file='server/api/registry/npmjs-versions/[...pkg].get.ts'

sed -n '1,120p' "$route_file"
printf '\n---\n'
rg -n "getRouterParam\(event, 'pkg'\)|decodeURIComponent|parsePackageParams" "$route_file"

Repository: npmx-dev/npmx.dev

Length of output: 1314


🏁 Script executed:

cat -n app/pages/package/[[org]]/[name]/versions.vue | sed -n '60,75p'

Repository: npmx-dev/npmx.dev

Length of output: 654


🏁 Script executed:

# Check how other similar routes handle package names to compare
rg -A 5 "getRouterParam.*pkg" server/api/registry/ | head -50

Repository: npmx-dev/npmx.dev

Length of output: 3628


🏁 Script executed:

# Look for how other API routes handle scoped packages
fd -e ts server/api/registry | head -10 | xargs grep -l "decodeURIComponent\|parsePackageParams" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 442


🏁 Script executed:

# Find and examine the fetchNpmVersionDownloadsFromApi function
rg -n "fetchNpmVersionDownloadsFromApi" --type ts | head -3

Repository: npmx-dev/npmx.dev

Length of output: 421


🏁 Script executed:

# Check for tests related to scoped packages and npmjs-versions
rg -r "npmjs-versions|scoped" test/ --type ts --type js | grep -i "scoped\|@" | head -10

Repository: npmx-dev/npmx.dev

Length of output: 1201


🏁 Script executed:

# Check the versions.vue template for aria-label usage on download counts
rg -n "aria-label" app/pages/package/[[org]]/[name]/versions.vue

Repository: npmx-dev/npmx.dev

Length of output: 466


🏁 Script executed:

# Examine the fetchNpmVersionDownloadsFromApi function
sed -n '1,40p' server/utils/npm-website-versions.ts

Repository: npmx-dev/npmx.dev

Length of output: 1014


🏁 Script executed:

# Search for tests specific to npmjs-versions endpoint
rg -n "npmjs-versions" test/ -A 3 -B 1 | head -30

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check the template context around aria-label occurrences for the download counts
sed -n '295,300p' app/pages/package/[[org]]/[name]/versions.vue
sed -n '352,357p' app/pages/package/[[org]]/[name]/versions.vue
sed -n '446,451p' app/pages/package/[[org]]/[name]/versions.vue

Repository: npmx-dev/npmx.dev

Length of output: 1073


🏁 Script executed:

# Find encodePackageName function
rg -n "encodePackageName" --type ts | head -5

Repository: npmx-dev/npmx.dev

Length of output: 513


🏁 Script executed:

# View the encodePackageName function
rg -B 3 -A 8 "export.*encodePackageName|function encodePackageName" --type ts

Repository: npmx-dev/npmx.dev

Length of output: 1338


🏁 Script executed:

# Check for E2E tests on the versions page specifically
rg -n "versions.vue\|versions.spec" test/e2e/ --type ts --type js

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for any issues or comments about scoped packages with download counts
rg "scoped.*download|download.*scoped" . --type ts --type vue --type js -i

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check if there are any failing tests or skip markers related to scoped packages
rg "skip\|xdescribe\|xit" test/e2e/ -A 3 -B 1 | grep -A 3 -B 1 "versions\|scoped" -i

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Verify whether there's a separate test file for versions route
fd "versions" test/ --type ts --type js

Repository: npmx-dev/npmx.dev

Length of output: 230


🏁 Script executed:

# Check related API routes for test coverage patterns
find test -name "*.spec.ts" -o -name "*.test.ts" | xargs grep -l "api/registry" | head -5

Repository: npmx-dev/npmx.dev

Length of output: 241


🏁 Script executed:

# Look at existing tests for package-meta to see if they test scoped packages
grep -r "scoped\|@" test --include="*.ts" --include="*.js" | grep -i package | head -10

Repository: npmx-dev/npmx.dev

Length of output: 1054


🏁 Script executed:

# Check the git history of this file for context
git log --oneline app/pages/package/[[org]]/[name]/versions.vue | head -5

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:

Score: 42
(prefer visible context, aria-hidden hides number if decorative). WCAG SC: 4.1.2 Name, Role, Value (ARIA14 sufficient); 1.3.1 Info and Relationships; 2.5.3 Label in Name (@2).

Citations:


Accessibility issue with numeric content lacking semantic markup.

The aria-label attribute 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 ignore aria-label on elements without an explicit ARIA role or semantic meaning. Add contextual surrounding text visible to screen readers, use aria-labelledby to 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. While encodePackageName() correctly handles scoped packages, no E2E tests verify the complete integration with @scope/name packages. Before shipping, confirm the download counts render correctly for scoped packages in the versions view.


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.

Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

On a non-interactive HTML <span> or <div>, does aria-label override the element's visible text in the accessibility tree? What is the recommended accessible pattern for a numeric download count that needs a spoken label?

💡 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 aria-label or ensure it matches the visible text content.

The aria-label attribute currently contains only the translated label (likely "downloads"), whilst the visible text shows the formatted number. This creates a label-in-name mismatch. Either remove aria-label and rely on the visible text alone, or update it to include the complete phrase (number + unit) to match what users see on screen.

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"
Expand Down Expand Up @@ -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)!"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -413,7 +489,7 @@ const flatItems = computed<FlatItem[]>(() => {
"
dir="ltr"
>
{{ item.version }}
v{{ item.version }}
</LinkBase>
<div
v-if="versionToTagsMap.get(item.version)?.length"
Expand All @@ -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)"
Expand Down Expand Up @@ -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"
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,
}))
}
Loading