diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 7cf6b4c8bc..100adc07a5 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -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 }>() @@ -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 || @@ -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" /> @@ -343,6 +355,14 @@ const fundingUrl = computed(() => { > {{ $t('compare.compare_versions') }} + + {{ $t('package.links.dependents') }} + diff --git a/app/pages/package/[[org]]/[name]/dependents.vue b/app/pages/package/[[org]]/[name]/dependents.vue new file mode 100644 index 0000000000..38254b340a --- /dev/null +++ b/app/pages/package/[[org]]/[name]/dependents.vue @@ -0,0 +1,183 @@ + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 02c1af000f..4121d6ec5b 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -207,6 +207,8 @@ "members": "members" }, "scroll_to_top": "Scroll to top", + "previous": "Previous", + "next": "Next", "cancel": "Cancel", "save": "Save", "edit": "Edit", @@ -318,7 +320,8 @@ "docs": "docs", "fund": "fund", "compare": "compare", - "compare_this_package": "compare this package" + "compare_this_package": "compare this package", + "dependents": "dependents" }, "likes": { "like": "Like this package", @@ -634,6 +637,13 @@ "download": { "button": "Download", "tarball": "Download Tarball as .tar.gz" + }, + "dependents": { + "title": "Dependents", + "subtitle": "Packages that depend on {name}", + "count": "{count} dependent | {count} dependents", + "none": "No packages found that depend on {name}", + "error": "Failed to load dependents" } }, "connector": { diff --git a/i18n/schema.json b/i18n/schema.json index 885bd6eb5e..425e8d2a94 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -625,6 +625,12 @@ "scroll_to_top": { "type": "string" }, + "previous": { + "type": "string" + }, + "next": { + "type": "string" + }, "cancel": { "type": "string" }, @@ -960,6 +966,9 @@ }, "compare_this_package": { "type": "string" + }, + "dependents": { + "type": "string" } }, "additionalProperties": false @@ -1908,6 +1917,27 @@ } }, "additionalProperties": false + }, + "dependents": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "count": { + "type": "string" + }, + "none": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/server/api/registry/dependents/[...pkg].get.ts b/server/api/registry/dependents/[...pkg].get.ts new file mode 100644 index 0000000000..bb87717159 --- /dev/null +++ b/server/api/registry/dependents/[...pkg].get.ts @@ -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:` 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(NPM_SEARCH_BASE, { + query: { + text: `dependencies:${packageName}`, + size, + from, + }, + }) + + 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}` + }, + }, +) diff --git a/test/unit/app/server/dependents.spec.ts b/test/unit/app/server/dependents.spec.ts new file mode 100644 index 0000000000..5132f7d5a6 --- /dev/null +++ b/test/unit/app/server/dependents.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' + +/** + * Unit tests for the dependents API response parsing logic. + * + * The `/api/registry/dependents/[...pkg]` endpoint fetches from the npm + * search API using a `dependencies:` query and maps the response + * to a simplified shape. These tests verify the mapping logic in isolation. + */ + +interface NpmSearchObject { + package: { + name: string + version: string + description?: string + date?: string + } + score: { final: number } + searchScore: number +} + +function mapNpmSearchResponse( + objects: NpmSearchObject[], + total: number, + page: number, + size: number, +) { + return { + total, + page, + size, + packages: 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, + })), + } +} + +describe('dependents API response mapping', () => { + it('maps npm search objects to the expected package shape', () => { + const objects: NpmSearchObject[] = [ + { + package: { + name: 'some-lib', + version: '1.2.3', + description: 'A library', + date: '2024-01-01', + }, + score: { final: 0.95 }, + searchScore: 100, + }, + ] + + const result = mapNpmSearchResponse(objects, 42, 0, 20) + + expect(result.total).toBe(42) + expect(result.page).toBe(0) + expect(result.size).toBe(20) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]).toEqual({ + name: 'some-lib', + version: '1.2.3', + description: 'A library', + date: '2024-01-01', + score: 0.95, + }) + }) + + it('falls back to null for missing optional fields', () => { + const objects: NpmSearchObject[] = [ + { + package: { name: 'minimal-pkg', version: '0.1.0' }, + score: { final: 0.5 }, + searchScore: 50, + }, + ] + + const result = mapNpmSearchResponse(objects, 1, 0, 20) + expect(result.packages[0]!.description).toBeNull() + expect(result.packages[0]!.date).toBeNull() + }) + + it('returns empty packages array when objects is empty', () => { + const result = mapNpmSearchResponse([], 0, 0, 20) + expect(result.packages).toHaveLength(0) + expect(result.total).toBe(0) + }) + + it('computes correct page offset for pagination', () => { + // page=2, size=20 means from=40 + const page = 2 + const size = 20 + const from = page * size + expect(from).toBe(40) + }) + + it('caps size to a maximum of 50', () => { + const raw = 200 + const capped = Math.min(50, Math.max(1, raw)) + expect(capped).toBe(50) + }) + + it('enforces a minimum size of 1', () => { + const raw = 0 + const capped = Math.min(50, Math.max(1, raw)) + expect(capped).toBe(1) + }) +})