Skip to content

Commit f6b48d6

Browse files
committed
feat: add package dependents list page (#2036)
1 parent 7f2fc1a commit f6b48d6

File tree

3 files changed

+295
-1
lines changed

3 files changed

+295
-1
lines changed

app/components/Package/Header.vue

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const props = defineProps<{
1010
latestVersion?: SlimVersion | null
1111
provenanceData?: ProvenanceDetails | null
1212
provenanceStatus?: string | null
13-
page: 'main' | 'docs' | 'code' | 'diff'
13+
page: 'main' | 'docs' | 'code' | 'diff' | 'dependents'
1414
versionUrlPattern: string
1515
}>()
1616
@@ -108,6 +108,18 @@ const mainLink = computed((): RouteLocationRaw | null => {
108108
return packageRoute(props.pkg.name, props.resolvedVersion)
109109
})
110110
111+
const dependentsLink = computed((): RouteLocationRaw | null => {
112+
if (props.pkg == null) return null
113+
const split = props.pkg.name.split('/')
114+
return {
115+
name: 'package-dependents',
116+
params: {
117+
org: split.length === 2 ? split[0] : undefined,
118+
name: split.length === 2 ? split[1]! : split[0]!,
119+
},
120+
}
121+
})
122+
111123
const diffLink = computed((): RouteLocationRaw | null => {
112124
if (
113125
props.pkg == null ||
@@ -343,6 +355,14 @@ const fundingUrl = computed(() => {
343355
>
344356
{{ $t('compare.compare_versions') }}
345357
</LinkBase>
358+
<LinkBase
359+
v-if="dependentsLink"
360+
:to="dependentsLink"
361+
class="decoration-none border-b-2 p-1 hover:border-accent/50 focus-visible:[outline-offset:-2px]!"
362+
:class="page === 'dependents' ? 'border-accent text-accent!' : 'border-transparent'"
363+
>
364+
{{ $t('package.links.dependents') }}
365+
</LinkBase>
346366
</nav>
347367
</div>
348368
</div>
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<script setup lang="ts">
2+
definePageMeta({
3+
name: 'package-dependents',
4+
scrollMargin: 200,
5+
})
6+
7+
const route = useRoute('package-dependents')
8+
9+
const packageName = computed(() => {
10+
const { org, name } = route.params
11+
return org ? `${org}/${name}` : name
12+
})
13+
14+
const { data: pkg } = usePackage(packageName)
15+
16+
const resolvedVersion = computed(() => {
17+
const latest = pkg.value?.['dist-tags']?.latest
18+
if (!latest) return null
19+
return latest
20+
})
21+
22+
const displayVersion = computed(() => {
23+
if (!pkg.value || !resolvedVersion.value) return null
24+
return pkg.value.versions[resolvedVersion.value] ?? null
25+
})
26+
27+
const latestVersion = computed(() => displayVersion.value ?? null)
28+
29+
const versionUrlPattern = computed(() => {
30+
const split = packageName.value.split('/')
31+
if (split.length === 2) {
32+
return `/package/${split[0]}/${split[1]}/v/{version}`
33+
}
34+
return `/package/${packageName.value}/v/{version}`
35+
})
36+
37+
const page = shallowRef(0)
38+
const PAGE_SIZE = 20
39+
40+
interface DependentsResponse {
41+
total: number
42+
page: number
43+
size: number
44+
packages: Array<{
45+
name: string
46+
version: string
47+
description: string | null
48+
date: string | null
49+
score: number
50+
}>
51+
}
52+
53+
const { data, status, refresh } = useLazyFetch<DependentsResponse>(
54+
() => `/api/registry/dependents/${packageName.value}`,
55+
{
56+
query: computed(() => ({ page: page.value, size: PAGE_SIZE })),
57+
watch: [page],
58+
},
59+
)
60+
61+
const totalPages = computed(() => {
62+
if (!data.value?.total) return 0
63+
return Math.ceil(data.value.total / PAGE_SIZE)
64+
})
65+
66+
function prevPage() {
67+
if (page.value > 0) {
68+
page.value--
69+
window.scrollTo({ top: 0, behavior: 'smooth' })
70+
}
71+
}
72+
73+
function nextPage() {
74+
if (page.value < totalPages.value - 1) {
75+
page.value++
76+
window.scrollTo({ top: 0, behavior: 'smooth' })
77+
}
78+
}
79+
80+
const numberFormatter = useNumberFormatter()
81+
82+
useSeoMeta({
83+
title: () => `Dependents - ${packageName.value} - npmx`,
84+
description: () => `Packages that depend on ${packageName.value}`,
85+
})
86+
</script>
87+
88+
<template>
89+
<main class="flex-1 pb-8">
90+
<PackageHeader
91+
:pkg="pkg ?? null"
92+
:resolved-version="resolvedVersion"
93+
:display-version="displayVersion"
94+
:latest-version="latestVersion"
95+
:version-url-pattern="versionUrlPattern"
96+
page="dependents"
97+
/>
98+
99+
<div class="container py-6">
100+
<h1 class="font-mono text-xl font-semibold mb-1">
101+
{{ $t('package.dependents.title') }}
102+
</h1>
103+
<p class="text-sm text-fg-muted mb-6">
104+
{{ $t('package.dependents.subtitle', { name: packageName }) }}
105+
</p>
106+
107+
<!-- Loading state -->
108+
<div v-if="status === 'pending'" class="space-y-2">
109+
<SkeletonInline v-for="i in 10" :key="i" class="h-16 w-full rounded-md" />
110+
</div>
111+
112+
<!-- Error state -->
113+
<div v-else-if="status === 'error'" class="py-12 text-center">
114+
<p class="text-fg-muted mb-4">{{ $t('package.dependents.error') }}</p>
115+
<ButtonBase @click="refresh()">{{ $t('common.retry') }}</ButtonBase>
116+
</div>
117+
118+
<!-- Empty state -->
119+
<div v-else-if="!data?.packages?.length" class="py-12 text-center">
120+
<span class="i-lucide:package-x w-12 h-12 mx-auto mb-4 text-fg-subtle block" />
121+
<p class="text-fg-muted">{{ $t('package.dependents.none', { name: packageName }) }}</p>
122+
</div>
123+
124+
<!-- Results -->
125+
<template v-else>
126+
<p class="text-xs text-fg-subtle mb-4 font-mono">
127+
{{
128+
$t(
129+
'package.dependents.count',
130+
{ count: numberFormatter.format(data.total) },
131+
data.total,
132+
)
133+
}}
134+
</p>
135+
136+
<ul class="space-y-2 list-none m-0 p-0">
137+
<li
138+
v-for="dep in data.packages"
139+
:key="dep.name"
140+
class="border border-border rounded-md p-4 hover:border-border-hover transition-colors"
141+
>
142+
<div class="flex items-start justify-between gap-4">
143+
<div class="min-w-0 flex-1">
144+
<LinkBase
145+
:to="packageRoute(dep.name)"
146+
class="font-mono text-sm font-medium"
147+
dir="ltr"
148+
>
149+
{{ dep.name }}
150+
</LinkBase>
151+
<p v-if="dep.description" class="text-xs text-fg-muted mt-1 line-clamp-2">
152+
{{ dep.description }}
153+
</p>
154+
</div>
155+
<span class="font-mono text-xs text-fg-subtle shrink-0" dir="ltr">
156+
{{ dep.version }}
157+
</span>
158+
</div>
159+
</li>
160+
</ul>
161+
162+
<!-- Pagination -->
163+
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6">
164+
<ButtonBase
165+
variant="secondary"
166+
classicon="i-lucide:chevron-left"
167+
:disabled="page === 0"
168+
@click="prevPage"
169+
>
170+
{{ $t('common.previous') }}
171+
</ButtonBase>
172+
<span class="text-sm text-fg-muted font-mono">
173+
{{ page + 1 }} / {{ totalPages }}
174+
</span>
175+
<ButtonBase
176+
variant="secondary"
177+
:disabled="page >= totalPages - 1"
178+
@click="nextPage"
179+
>
180+
{{ $t('common.next') }}
181+
<span class="i-lucide:chevron-right w-4 h-4" aria-hidden="true" />
182+
</ButtonBase>
183+
</div>
184+
</template>
185+
</div>
186+
</main>
187+
</template>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { CACHE_MAX_AGE_FIVE_MINUTES, NPM_REGISTRY } from '#shared/utils/constants'
2+
3+
const NPM_SEARCH_BASE = 'https://registry.npmjs.org/-/v1/search'
4+
5+
interface NpmSearchResult {
6+
objects: Array<{
7+
package: {
8+
name: string
9+
version: string
10+
description?: string
11+
date?: string
12+
links?: {
13+
npm?: string
14+
homepage?: string
15+
repository?: string
16+
}
17+
}
18+
score: {
19+
final: number
20+
}
21+
searchScore: number
22+
}>
23+
total: number
24+
time: string
25+
}
26+
27+
/**
28+
* GET /api/registry/dependents/:name
29+
*
30+
* Returns packages that depend on the given package,
31+
* using the npm search API with `dependencies:<name>` query.
32+
*/
33+
export default defineCachedEventHandler(
34+
async event => {
35+
const pkgSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
36+
const rawName = pkgSegments.join('/')
37+
const packageName = decodeURIComponent(rawName)
38+
39+
const query = getQuery(event)
40+
const page = Math.max(0, Number(query.page ?? 0))
41+
const size = Math.min(50, Math.max(1, Number(query.size ?? 20)))
42+
const from = page * size
43+
44+
if (!packageName) {
45+
throw createError({ statusCode: 400, message: 'Package name is required' })
46+
}
47+
48+
try {
49+
const data = await $fetch<NpmSearchResult>(NPM_SEARCH_BASE, {
50+
query: {
51+
text: `dependencies:${packageName}`,
52+
size,
53+
from,
54+
},
55+
})
56+
57+
return {
58+
total: data.total,
59+
page,
60+
size,
61+
packages: data.objects.map(obj => ({
62+
name: obj.package.name,
63+
version: obj.package.version,
64+
description: obj.package.description ?? null,
65+
date: obj.package.date ?? null,
66+
score: obj.score.final,
67+
})),
68+
}
69+
} catch {
70+
return {
71+
total: 0,
72+
page,
73+
size,
74+
packages: [],
75+
}
76+
}
77+
},
78+
{
79+
maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
80+
swr: true,
81+
getKey: event => {
82+
const pkg = getRouterParam(event, 'pkg') ?? ''
83+
const query = getQuery(event)
84+
return `dependents:v1:${pkg}:p${query.page ?? 0}:s${query.size ?? 20}`
85+
},
86+
},
87+
)

0 commit comments

Comments
 (0)