-
-
Notifications
You must be signed in to change notification settings - Fork 425
feat: add package dependents list page #2208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
f6b48d6
22cb7f5
143ad3f
b8dd395
f6f50c0
c260e85
23ac526
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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 -20Repository: npmx-dev/npmx.dev Length of output: 152 🏁 Script executed: # Let's search for the specific file pattern
fd "dependents.*pkg" --type fRepository: 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 dependentsRepository: npmx-dev/npmx.dev Length of output: 195 🏁 Script executed: cat -n server/api/registry/dependents/\[...\pkg\].get.tsRepository: npmx-dev/npmx.dev Length of output: 2768 Add The current implementation passes only |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache key may not match decoded package name. The cache key uses the raw route param 🔧 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
Suggested change
|
||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.