Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
24 changes: 22 additions & 2 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const props = defineProps<{
latestVersion?: SlimVersion | null
provenanceData?: ProvenanceDetails | null
provenanceStatus?: string | null
page: 'main' | 'docs' | 'code' | 'diff'
page: 'main' | 'docs' | 'code' | 'diff' | 'dependents'
versionUrlPattern: string
}>()

Expand Down Expand Up @@ -108,6 +108,18 @@ const mainLink = computed((): RouteLocationRaw | null => {
return packageRoute(props.pkg.name, props.resolvedVersion)
})

const dependentsLink = computed((): RouteLocationRaw | null => {
if (props.pkg == null) return null
const split = props.pkg.name.split('/')
return {
name: 'package-dependents',
params: {
org: split.length === 2 ? split[0] : undefined,
name: split.length === 2 ? split[1]! : split[0]!,
},
}
})

const diffLink = computed((): RouteLocationRaw | null => {
if (
props.pkg == null ||
Expand Down Expand Up @@ -271,7 +283,7 @@ const fundingUrl = computed(() => {
:to="packageRoute(packageName, resolvedVersion, '#provenance')"
:aria-label="$t('package.provenance_section.view_more_details')"
classicon="i-lucide:shield-check"
class="py-1.25 px-2 me-2"
class="py-1.5 px-2 me-2"
/>
</TooltipApp>
</template>
Expand Down Expand Up @@ -343,6 +355,14 @@ const fundingUrl = computed(() => {
>
{{ $t('compare.compare_versions') }}
</LinkBase>
<LinkBase
v-if="dependentsLink"
:to="dependentsLink"
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
:class="page === 'dependents' ? 'border-accent text-accent!' : 'border-transparent'"
>
{{ $t('package.links.dependents') }}
</LinkBase>
</nav>
</div>
</div>
Expand Down
183 changes: 183 additions & 0 deletions app/pages/package/[[org]]/[name]/dependents.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<script setup lang="ts">
definePageMeta({
name: 'package-dependents',
scrollMargin: 200,
})

const route = useRoute('package-dependents')

const packageName = computed(() => {
const { org, name } = route.params
return org ? `${org}/${name}` : name
})

const { data: pkg } = usePackage(packageName)

const resolvedVersion = computed(() => {
const latest = pkg.value?.['dist-tags']?.latest
if (!latest) return null
return latest
})

const displayVersion = computed(() => pkg.value?.requestedVersion ?? null)

const latestVersion = computed(() => {
if (!pkg.value) return null
const latestTag = pkg.value['dist-tags']?.latest
if (!latestTag) return null
return pkg.value.versions[latestTag] ?? null
})

const versionUrlPattern = computed(() => {
const split = packageName.value.split('/')
if (split.length === 2) {
return `/package/${split[0]}/${split[1]}/v/{version}`
}
return `/package/${packageName.value}/v/{version}`
})

const page = shallowRef(0)
const PAGE_SIZE = 20

interface DependentsResponse {
total: number
page: number
size: number
packages: Array<{
name: string
version: string
description: string | null
date: string | null
score: number
}>
}

const { data, status, refresh } = useLazyFetch<DependentsResponse>(
() => `/api/registry/dependents/${packageName.value}`,
{
query: computed(() => ({ page: page.value, size: PAGE_SIZE })),
watch: [page],
},
)

const totalPages = computed(() => {
if (!data.value?.total) return 0
return Math.ceil(data.value.total / PAGE_SIZE)
})

function prevPage() {
if (page.value > 0) {
page.value--
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}

function nextPage() {
if (page.value < totalPages.value - 1) {
page.value++
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}

const numberFormatter = useNumberFormatter()

useSeoMeta({
title: () => `Dependents - ${packageName.value} - npmx`,
description: () => `Packages that depend on ${packageName.value}`,
})
</script>

<template>
<main class="flex-1 pb-8">
<PackageHeader
:pkg="pkg ?? null"
:resolved-version="resolvedVersion"
:display-version="displayVersion"
:latest-version="latestVersion"
:version-url-pattern="versionUrlPattern"
page="dependents"
/>

<div class="container py-6">
<h1 class="font-mono text-xl font-semibold mb-1">
{{ $t('package.dependents.title') }}
</h1>
<p class="text-sm text-fg-muted mb-6">
{{ $t('package.dependents.subtitle', { name: packageName }) }}
</p>

<!-- Loading state -->
<div v-if="status === 'pending'" class="space-y-2">
<SkeletonInline v-for="i in 10" :key="i" class="h-16 w-full rounded-md" />
</div>

<!-- Error state -->
<div v-else-if="status === 'error'" class="py-12 text-center">
<p class="text-fg-muted mb-4">{{ $t('package.dependents.error') }}</p>
<ButtonBase @click="refresh()">{{ $t('common.retry') }}</ButtonBase>
</div>

<!-- Empty state -->
<div v-else-if="!data?.packages?.length" class="py-12 text-center">
<span class="i-lucide:package-x w-12 h-12 mx-auto mb-4 text-fg-subtle block" />
<p class="text-fg-muted">{{ $t('package.dependents.none', { name: packageName }) }}</p>
</div>

<!-- Results -->
<template v-else>
<p class="text-xs text-fg-subtle mb-4 font-mono">
{{
$t(
'package.dependents.count',
{ count: numberFormatter.format(data.total) },
data.total,
)
}}
</p>

<ul class="space-y-2 list-none m-0 p-0">
<li
v-for="dep in data.packages"
:key="dep.name"
class="border border-border rounded-md p-4 hover:border-border-hover transition-colors"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<LinkBase
:to="packageRoute(dep.name)"
class="font-mono text-sm font-medium"
dir="ltr"
>
{{ dep.name }}
</LinkBase>
<p v-if="dep.description" class="text-xs text-fg-muted mt-1 line-clamp-2">
{{ dep.description }}
</p>
</div>
<span class="font-mono text-xs text-fg-subtle shrink-0" dir="ltr">
{{ dep.version }}
</span>
</div>
</li>
</ul>

<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6">
<ButtonBase
variant="secondary"
classicon="i-lucide:chevron-left"
:disabled="page === 0"
@click="prevPage"
>
{{ $t('common.previous') }}
</ButtonBase>
<span class="text-sm text-fg-muted font-mono"> {{ page + 1 }} / {{ totalPages }} </span>
<ButtonBase variant="secondary" :disabled="page >= totalPages - 1" @click="nextPage">
{{ $t('common.next') }}
<span class="i-lucide:chevron-right w-4 h-4" aria-hidden="true" />
</ButtonBase>
</div>
</template>
</div>
</main>
</template>
33 changes: 33 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,12 @@
"scroll_to_top": {
"type": "string"
},
"previous": {
"type": "string"
},
"next": {
"type": "string"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"cancel": {
"type": "string"
},
Expand Down Expand Up @@ -960,6 +966,9 @@
},
"compare_this_package": {
"type": "string"
},
"dependents": {
"type": "string"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -1402,6 +1411,27 @@
},
"additionalProperties": false
},
"dependents": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"subtitle": {
"type": "string"
},
"count": {
"type": "string"
},
"error": {
"type": "string"
},
"none": {
"type": "string"
}
},
"additionalProperties": false
},
"maintainers": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -2494,6 +2524,9 @@
"back_to_package": {
"type": "string"
},
"toggle_word_wrap": {
"type": "string"
},
"table": {
"type": "object",
"properties": {
Expand Down
87 changes: 87 additions & 0 deletions server/api/registry/dependents/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants'

const NPM_SEARCH_BASE = 'https://registry.npmjs.org/-/v1/search'

interface NpmSearchResult {
objects: Array<{
package: {
name: string
version: string
description?: string
date?: string
links?: {
npm?: string
homepage?: string
repository?: string
}
}
score: {
final: number
}
searchScore: number
}>
total: number
time: string
}

/**
* GET /api/registry/dependents/:name
*
* Returns packages that depend on the given package,
* using the npm search API with `dependencies:<name>` query.
*/
export default defineCachedEventHandler(
async event => {
const pkgSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
const rawName = pkgSegments.join('/')
const packageName = decodeURIComponent(rawName)

const query = getQuery(event)
const page = Math.max(0, Number(query.page ?? 0))
const size = Math.min(50, Math.max(1, Number(query.size ?? 20)))
const from = page * size

if (!packageName) {
throw createError({ statusCode: 400, message: 'Package name is required' })
}

try {
const data = await $fetch<NpmSearchResult>(NPM_SEARCH_BASE, {
query: {
text: `dependencies:${packageName}`,
size,
from,
},
})
Comment on lines +48 to +55
Copy link
Copy Markdown
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

🌐 Web query:

npm registry search API sort by popularity or downloads

💡 Result:

Yes, the npm registry provides a search API endpoint that supports sorting by popularity or downloads via weighted scoring factors. The endpoint is GET https://registry.npmjs.org/-/v1/search. Key query parameters for sorting: - text: the search query (required for searching) - size: number of results (default 20, max 250) - from: offset for pagination - quality: weight (0-1) for quality score - popularity: weight (0-1) for popularity score (based on downloads and other metrics) - maintenance: weight (0-1) for maintenance score To sort primarily by popularity, set popularity=1.0&quality=0&maintenance=0. Results are scored as a weighted combination, normalized as a unit vector. Example to get top 10 results for "react" sorted by popularity: curl "https://registry.npmjs.org/-/v1/search?text=react&size=10&popularity=1.0&quality=0&maintenance=0" Each result includes a "score" object with final score and detail breakdown (quality, popularity, maintenance), plus searchScore for text relevance. This is the official public API, actively used by libraries like libnpmsearch (npm/npm/libnpmsearch), which passes these opts directly to the endpoint. Popularity incorporates download stats and other factors. Note: No direct "sort=downloads" parameter; use popularity weighting. For exact download counts, use separate /downloads/point endpoints, but not for search sorting.

Citations:


🏁 Script executed:

# First, locate and examine the file
find . -name "[...pkg].get.ts" -o -name "*dependents*" -type f | head -20

Repository: npmx-dev/npmx.dev

Length of output: 152


🏁 Script executed:

# Let's search for the specific file pattern
fd "dependents.*pkg" --type f

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for the file in api registry directory
fd -t f "pkg" server/api/registry/dependents/

Repository: npmx-dev/npmx.dev

Length of output: 107


🏁 Script executed:

# Try a broader search
git ls-files | grep -i dependents

Repository: npmx-dev/npmx.dev

Length of output: 195


🏁 Script executed:

cat -n server/api/registry/dependents/\[...\pkg\].get.ts

Repository: npmx-dev/npmx.dev

Length of output: 2768


Add popularity weighting to npm search query to sort dependents by download count.

The current implementation passes only text, size, and from parameters to the npm search API, which returns results sorted by relevance. PR objective #31 requires sorting by download count. The npm search API supports popularity weighting via the popularity parameter (0–1 scale, based on downloads). To prioritise downloads, add popularity: 1.0, quality: 0, maintenance: 0 to the query object (lines 50–54).


return {
total: data.total,
page,
size,
packages: data.objects.map(obj => ({
name: obj.package.name,
version: obj.package.version,
description: obj.package.description ?? null,
date: obj.package.date ?? null,
score: obj.score.final,
})),
}
} catch {
return {
total: 0,
page,
size,
packages: [],
}
}
},
{
maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
swr: true,
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
const query = getQuery(event)
return `dependents:v1:${pkg}:p${query.page ?? 0}:s${query.size ?? 20}`
Comment on lines +81 to +84
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Cache key may not match decoded package name.

The cache key uses the raw route param getRouterParam(event, 'pkg'), but the handler decodes the package name with decodeURIComponent(rawName) on line 37. For URL-encoded package names (e.g., %40scope%2Fpkg), the cache key and actual query could diverge, causing unnecessary cache misses or stale entries.

🔧 Proposed fix to use decoded package name in cache key
   getKey: event => {
     const pkg = getRouterParam(event, 'pkg') ?? ''
+    const decodedPkg = decodeURIComponent(pkg)
     const query = getQuery(event)
-    return `dependents:v1:${pkg}:p${query.page ?? 0}:s${query.size ?? 20}`
+    return `dependents:v1:${decodedPkg}:p${query.page ?? 0}:s${query.size ?? 20}`
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
const query = getQuery(event)
return `dependents:v1:${pkg}:p${query.page ?? 0}:s${query.size ?? 20}`
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
const decodedPkg = decodeURIComponent(pkg)
const query = getQuery(event)
return `dependents:v1:${decodedPkg}:p${query.page ?? 0}:s${query.size ?? 20}`
},

},
},
)
Loading
Loading