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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ What npmx offers:
- **Version range resolution** – dependency ranges (e.g., `^1.0.0`) resolve to actual installed versions
- **Claim new packages** – register new package names directly from search results (via local connector)
- **Clickable version tags** – navigate directly to any version from the versions list
- **Dependents list** – browse packages that depend on a given package

### User & org pages

Expand Down Expand Up @@ -90,7 +91,7 @@ What npmx offers:
| Keyboard navigation | ❌ | ✅ |
| Multi-provider repo support | ❌ | ✅ |
| Version range resolution | ❌ | ✅ |
| Dependents list | ✅ | 🚧 |
| Dependents list | ✅ | |
| Package admin (access/owners) | ✅ | 🚧 |
| Org/team management | ✅ | 🚧 |
| 2FA/account settings | ✅ | ❌ |
Expand Down Expand Up @@ -119,7 +120,6 @@ npmx.dev supports npm permalinks – just replace `npmjs.com` with `npmx.dev
#### Not yet supported

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

### Simpler URLs
Expand Down
22 changes: 21 additions & 1 deletion 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 @@ -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') }}
Comment on lines +172 to +176
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

Pagination labels use non-existent i18n keys.

common.previous and common.next are not defined; this will render raw keys instead of labels.

🧩 Suggested key fix
-            {{ $t('common.previous') }}
+            {{ $t('filters.pagination.previous') }}
@@
-            {{ $t('common.next') }}
+            {{ $t('filters.pagination.next') }}
📝 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
{{ $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') }}
{{ $t('filters.pagination.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('filters.pagination.next') }}

<span class="i-lucide:chevron-right w-4 h-4" aria-hidden="true" />
</ButtonBase>
</div>
</template>
</div>
</main>
</template>
10 changes: 9 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,15 @@
"docs": "docs",
"fund": "fund",
"compare": "compare",
"compare_this_package": "compare this package"
"compare_this_package": "compare this package",
"dependents": "dependents"
},
"dependents": {
"title": "Dependents",
"subtitle": "Packages that list {name} as a dependency",
"count": "{count} dependent | {count} dependents",
"none": "No packages depend on {name} yet",
"error": "Failed to load dependents"
},
"likes": {
"like": "Like this package",
Expand Down
24 changes: 24 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,30 @@
},
"compare_this_package": {
"type": "string"
},
"dependents": {
"type": "string"
}
},
"additionalProperties": false
},
"dependents": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"subtitle": {
"type": "string"
},
"count": {
"type": "string"
},
"none": {
"type": "string"
},
"error": {
"type": "string"
}
},
"additionalProperties": false
Expand Down
52 changes: 52 additions & 0 deletions server/api/registry/dependents/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Returns a paginated list of packages that depend on the given package.
*
* Uses the npm search API with a `dependencies:` text filter.
*
* URL patterns:
* - /api/registry/dependents/packageName?page=0&size=20
* - /api/registry/dependents/@scope/packageName?page=0&size=20
*/
export default defineCachedEventHandler(
async event => {
const pkgParam = getRouterParam(event, 'pkg')
if (!pkgParam) {
throw createError({ statusCode: 400, message: 'Package name is required' })
}

const packageName = decodeURIComponent(pkgParam)
const query = getQuery(event)
const page = Number(query.page ?? 0)
const size = Math.min(Number(query.size ?? 20), 50)
Comment on lines +19 to +20
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

Normalise and clamp page/size before both fetch params and cache key.

Negative/NaN/decimal values currently pass through, which can produce invalid offsets and cache-key fragmentation for equivalent effective queries.

🔧 Suggested normalisation
-    const page = Number(query.page ?? 0)
-    const size = Math.min(Number(query.size ?? 20), 50)
+    const rawPage = Number(query.page ?? 0)
+    const rawSize = Number(query.size ?? 20)
+    const page = Number.isFinite(rawPage) ? Math.max(0, Math.trunc(rawPage)) : 0
+    const size = Number.isFinite(rawSize) ? Math.min(50, Math.max(1, Math.trunc(rawSize))) : 20
@@
     getKey: event => {
       const pkg = getRouterParam(event, 'pkg') ?? ''
       const query = getQuery(event)
-      return `dependents:${pkg}:${query.page ?? 0}:${query.size ?? 20}`
+      const keyPage = Number.isFinite(Number(query.page)) ? Math.max(0, Math.trunc(Number(query.page))) : 0
+      const keySize = Number.isFinite(Number(query.size))
+        ? Math.min(50, Math.max(1, Math.trunc(Number(query.size))))
+        : 20
+      return `dependents:${pkg}:${keyPage}:${keySize}`
     },

Also applies to: 46-49


const params = new URLSearchParams({
text: `dependencies:${packageName}`,
size: String(size),
from: String(page * size),
})

const response = await $fetch<NpmSearchResponse>(`${NPM_REGISTRY}/-/v1/search?${params}`)

return {
total: response.total,
page,
size,
packages: response.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 ?? 0,

Check failure on line 39 in server/api/registry/dependents/[...pkg].get.ts

View workflow job for this annotation

GitHub Actions / 💪 Type check

Property 'score' does not exist on type 'NpmSearchResult'.

Check failure on line 39 in server/api/registry/dependents/[...pkg].get.ts

View workflow job for this annotation

GitHub Actions / 💪 Type check

Property 'score' does not exist on type 'NpmSearchResult'.
})),
}
},
{
maxAge: CACHE_MAX_AGE_FIVE_MINUTES,
swr: true,
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
const query = getQuery(event)
return `dependents:${pkg}:${query.page ?? 0}:${query.size ?? 20}`
},
},
)
Loading