Skip to content

Commit 062195b

Browse files
committed
feat: add dependents page for package reverse-dep lookup
Adds /package/<name>/dependents showing which packages list this one as a dependency, using the npm search API's dependencies: text filter. - New server API at /api/registry/dependents/[...pkg] (paginated, cached) - New page at /package/[[org]]/[name]/dependents.vue - Dependents tab added to the package navigation header - i18n keys for the new page and nav label
1 parent 7f2fc1a commit 062195b

File tree

6 files changed

+293
-4
lines changed

6 files changed

+293
-4
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ What npmx offers:
5757
- **Version range resolution** &ndash; dependency ranges (e.g., `^1.0.0`) resolve to actual installed versions
5858
- **Claim new packages** &ndash; register new package names directly from search results (via local connector)
5959
- **Clickable version tags** &ndash; navigate directly to any version from the versions list
60+
- **Dependents list** &ndash; browse packages that depend on a given package
6061

6162
### User & org pages
6263

@@ -90,7 +91,7 @@ What npmx offers:
9091
| Keyboard navigation |||
9192
| Multi-provider repo support |||
9293
| Version range resolution |||
93-
| Dependents list || 🚧 |
94+
| Dependents list || |
9495
| Package admin (access/owners) || 🚧 |
9596
| Org/team management || 🚧 |
9697
| 2FA/account settings |||
@@ -119,7 +120,6 @@ npmx.dev supports npm permalinks &ndash; just replace `npmjs.com` with `npmx.dev
119120
#### Not yet supported
120121

121122
- `/package/<name>/access` &ndash; package access settings
122-
- `/package/<name>/dependents` &ndash; dependent packages list
123123
- `/settings/*` &ndash; account settings pages
124124

125125
### Simpler URLs

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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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(() => pkg.value?.requestedVersion ?? null)
23+
24+
const latestVersion = computed(() => {
25+
if (!pkg.value) return null
26+
const latestTag = pkg.value['dist-tags']?.latest
27+
if (!latestTag) return null
28+
return pkg.value.versions[latestTag] ?? null
29+
})
30+
31+
const versionUrlPattern = computed(() => {
32+
const split = packageName.value.split('/')
33+
if (split.length === 2) {
34+
return `/package/${split[0]}/${split[1]}/v/{version}`
35+
}
36+
return `/package/${packageName.value}/v/{version}`
37+
})
38+
39+
const page = shallowRef(0)
40+
const PAGE_SIZE = 20
41+
42+
interface DependentsResponse {
43+
total: number
44+
page: number
45+
size: number
46+
packages: Array<{
47+
name: string
48+
version: string
49+
description: string | null
50+
date: string | null
51+
score: number
52+
}>
53+
}
54+
55+
const { data, status, refresh } = useLazyFetch<DependentsResponse>(
56+
() => `/api/registry/dependents/${packageName.value}`,
57+
{
58+
query: computed(() => ({ page: page.value, size: PAGE_SIZE })),
59+
watch: [page],
60+
},
61+
)
62+
63+
const totalPages = computed(() => {
64+
if (!data.value?.total) return 0
65+
return Math.ceil(data.value.total / PAGE_SIZE)
66+
})
67+
68+
function prevPage() {
69+
if (page.value > 0) {
70+
page.value--
71+
window.scrollTo({ top: 0, behavior: 'smooth' })
72+
}
73+
}
74+
75+
function nextPage() {
76+
if (page.value < totalPages.value - 1) {
77+
page.value++
78+
window.scrollTo({ top: 0, behavior: 'smooth' })
79+
}
80+
}
81+
82+
const numberFormatter = useNumberFormatter()
83+
84+
useSeoMeta({
85+
title: () => `Dependents - ${packageName.value} - npmx`,
86+
description: () => `Packages that depend on ${packageName.value}`,
87+
})
88+
</script>
89+
90+
<template>
91+
<main class="flex-1 pb-8">
92+
<PackageHeader
93+
:pkg="pkg ?? null"
94+
:resolved-version="resolvedVersion"
95+
:display-version="displayVersion"
96+
:latest-version="latestVersion"
97+
:version-url-pattern="versionUrlPattern"
98+
page="dependents"
99+
/>
100+
101+
<div class="container py-6">
102+
<h1 class="font-mono text-xl font-semibold mb-1">
103+
{{ $t('package.dependents.title') }}
104+
</h1>
105+
<p class="text-sm text-fg-muted mb-6">
106+
{{ $t('package.dependents.subtitle', { name: packageName }) }}
107+
</p>
108+
109+
<!-- Loading state -->
110+
<div v-if="status === 'pending'" class="space-y-2">
111+
<SkeletonInline v-for="i in 10" :key="i" class="h-16 w-full rounded-md" />
112+
</div>
113+
114+
<!-- Error state -->
115+
<div v-else-if="status === 'error'" class="py-12 text-center">
116+
<p class="text-fg-muted mb-4">{{ $t('package.dependents.error') }}</p>
117+
<ButtonBase @click="refresh()">{{ $t('common.retry') }}</ButtonBase>
118+
</div>
119+
120+
<!-- Empty state -->
121+
<div v-else-if="!data?.packages?.length" class="py-12 text-center">
122+
<span class="i-lucide:package-x w-12 h-12 mx-auto mb-4 text-fg-subtle block" />
123+
<p class="text-fg-muted">{{ $t('package.dependents.none', { name: packageName }) }}</p>
124+
</div>
125+
126+
<!-- Results -->
127+
<template v-else>
128+
<p class="text-xs text-fg-subtle mb-4 font-mono">
129+
{{
130+
$t(
131+
'package.dependents.count',
132+
{ count: numberFormatter.format(data.total) },
133+
data.total,
134+
)
135+
}}
136+
</p>
137+
138+
<ul class="space-y-2 list-none m-0 p-0">
139+
<li
140+
v-for="dep in data.packages"
141+
:key="dep.name"
142+
class="border border-border rounded-md p-4 hover:border-border-hover transition-colors"
143+
>
144+
<div class="flex items-start justify-between gap-4">
145+
<div class="min-w-0 flex-1">
146+
<LinkBase
147+
:to="packageRoute(dep.name)"
148+
class="font-mono text-sm font-medium"
149+
dir="ltr"
150+
>
151+
{{ dep.name }}
152+
</LinkBase>
153+
<p v-if="dep.description" class="text-xs text-fg-muted mt-1 line-clamp-2">
154+
{{ dep.description }}
155+
</p>
156+
</div>
157+
<span class="font-mono text-xs text-fg-subtle shrink-0" dir="ltr">
158+
{{ dep.version }}
159+
</span>
160+
</div>
161+
</li>
162+
</ul>
163+
164+
<!-- Pagination -->
165+
<div v-if="totalPages > 1" class="flex items-center justify-between mt-6">
166+
<ButtonBase
167+
variant="secondary"
168+
classicon="i-lucide:chevron-left"
169+
:disabled="page === 0"
170+
@click="prevPage"
171+
>
172+
{{ $t('common.previous') }}
173+
</ButtonBase>
174+
<span class="text-sm text-fg-muted font-mono"> {{ page + 1 }} / {{ totalPages }} </span>
175+
<ButtonBase variant="secondary" :disabled="page >= totalPages - 1" @click="nextPage">
176+
{{ $t('common.next') }}
177+
<span class="i-lucide:chevron-right w-4 h-4" aria-hidden="true" />
178+
</ButtonBase>
179+
</div>
180+
</template>
181+
</div>
182+
</main>
183+
</template>

i18n/locales/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,15 @@
318318
"docs": "docs",
319319
"fund": "fund",
320320
"compare": "compare",
321-
"compare_this_package": "compare this package"
321+
"compare_this_package": "compare this package",
322+
"dependents": "dependents"
323+
},
324+
"dependents": {
325+
"title": "Dependents",
326+
"subtitle": "Packages that list {name} as a dependency",
327+
"count": "{count} dependent | {count} dependents",
328+
"none": "No packages depend on {name} yet",
329+
"error": "Failed to load dependents"
322330
},
323331
"likes": {
324332
"like": "Like this package",

i18n/schema.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,30 @@
960960
},
961961
"compare_this_package": {
962962
"type": "string"
963+
},
964+
"dependents": {
965+
"type": "string"
966+
}
967+
},
968+
"additionalProperties": false
969+
},
970+
"dependents": {
971+
"type": "object",
972+
"properties": {
973+
"title": {
974+
"type": "string"
975+
},
976+
"subtitle": {
977+
"type": "string"
978+
},
979+
"count": {
980+
"type": "string"
981+
},
982+
"none": {
983+
"type": "string"
984+
},
985+
"error": {
986+
"type": "string"
963987
}
964988
},
965989
"additionalProperties": false
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Returns a paginated list of packages that depend on the given package.
3+
*
4+
* Uses the npm search API with a `dependencies:` text filter.
5+
*
6+
* URL patterns:
7+
* - /api/registry/dependents/packageName?page=0&size=20
8+
* - /api/registry/dependents/@scope/packageName?page=0&size=20
9+
*/
10+
export default defineCachedEventHandler(
11+
async event => {
12+
const pkgParam = getRouterParam(event, 'pkg')
13+
if (!pkgParam) {
14+
throw createError({ statusCode: 400, message: 'Package name is required' })
15+
}
16+
17+
const packageName = decodeURIComponent(pkgParam)
18+
const query = getQuery(event)
19+
const page = Number(query.page ?? 0)
20+
const size = Math.min(Number(query.size ?? 20), 50)
21+
22+
const params = new URLSearchParams({
23+
text: `dependencies:${packageName}`,
24+
size: String(size),
25+
from: String(page * size),
26+
})
27+
28+
const response = await $fetch<NpmSearchResponse>(
29+
`${NPM_REGISTRY}/-/v1/search?${params}`,
30+
)
31+
32+
return {
33+
total: response.total,
34+
page,
35+
size,
36+
packages: response.objects.map(obj => ({
37+
name: obj.package.name,
38+
version: obj.package.version,
39+
description: obj.package.description ?? null,
40+
date: obj.package.date ?? null,
41+
score: obj.score?.final ?? 0,
42+
})),
43+
}
44+
},
45+
{
46+
maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
47+
swr: true,
48+
getKey: event => {
49+
const pkg = getRouterParam(event, 'pkg') ?? ''
50+
const query = getQuery(event)
51+
return `dependents:${pkg}:${query.page ?? 0}:${query.size ?? 20}`
52+
},
53+
},
54+
)

0 commit comments

Comments
 (0)