From f6b48d6f8c0f8b38324ddf3d0965ab1d1df5d0f2 Mon Sep 17 00:00:00 2001 From: Alexander Wondwossen Date: Sun, 22 Mar 2026 12:50:06 -0400 Subject: [PATCH 1/7] feat: add package dependents list page (#2036) --- app/components/Package/Header.vue | 22 ++- .../package/[[org]]/[name]/dependents.vue | 187 ++++++++++++++++++ .../api/registry/dependents/[...pkg].get.ts | 87 ++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 app/pages/package/[[org]]/[name]/dependents.vue create mode 100644 server/api/registry/dependents/[...pkg].get.ts diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 7cf6b4c8bc..bed47dd4e5 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 || @@ -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..e80de0f289 --- /dev/null +++ b/app/pages/package/[[org]]/[name]/dependents.vue @@ -0,0 +1,187 @@ + + + diff --git a/server/api/registry/dependents/[...pkg].get.ts b/server/api/registry/dependents/[...pkg].get.ts new file mode 100644 index 0000000000..d24505e9b2 --- /dev/null +++ b/server/api/registry/dependents/[...pkg].get.ts @@ -0,0 +1,87 @@ +import { CACHE_MAX_AGE_FIVE_MINUTES, NPM_REGISTRY } 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}` + }, + }, +) From 22cb7f5f7ec2bea3521131d92380cf77fa7c250a Mon Sep 17 00:00:00 2001 From: Alexander Wondwossen Date: Sun, 22 Mar 2026 14:26:38 -0400 Subject: [PATCH 2/7] fix(a11y): increase provenance link touch target size to meet 24px minimum --- app/components/Package/Header.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index bed47dd4e5..100adc07a5 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -283,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" /> From 143ad3fafa2595e5e45e783ebd8f3715d8a8266e Mon Sep 17 00:00:00 2001 From: Alexander Wondwossen Date: Sun, 22 Mar 2026 14:50:42 -0400 Subject: [PATCH 3/7] fix: resolve type errors and i18n schema for dependents page --- .../package/[[org]]/[name]/dependents.vue | 12 ++++--- i18n/schema.json | 33 +++++++++++++++++++ .../api/registry/dependents/[...pkg].get.ts | 2 +- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/app/pages/package/[[org]]/[name]/dependents.vue b/app/pages/package/[[org]]/[name]/dependents.vue index e80de0f289..edf77e8a9f 100644 --- a/app/pages/package/[[org]]/[name]/dependents.vue +++ b/app/pages/package/[[org]]/[name]/dependents.vue @@ -19,12 +19,14 @@ const resolvedVersion = computed(() => { return latest }) -const displayVersion = computed(() => { - if (!pkg.value || !resolvedVersion.value) return null - return pkg.value.versions[resolvedVersion.value] ?? null -}) +const displayVersion = computed(() => pkg.value?.requestedVersion ?? null) -const latestVersion = computed(() => displayVersion.value ?? 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('/') diff --git a/i18n/schema.json b/i18n/schema.json index 885bd6eb5e..008372dc39 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 @@ -1402,6 +1411,27 @@ }, "additionalProperties": false }, + "dependents": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "subtitle": { + "type": "string" + }, + "count": { + "type": "string" + }, + "error": { + "type": "string" + }, + "none": { + "type": "string" + } + }, + "additionalProperties": false + }, "maintainers": { "type": "object", "properties": { @@ -2494,6 +2524,9 @@ "back_to_package": { "type": "string" }, + "toggle_word_wrap": { + "type": "string" + }, "table": { "type": "object", "properties": { diff --git a/server/api/registry/dependents/[...pkg].get.ts b/server/api/registry/dependents/[...pkg].get.ts index d24505e9b2..bb87717159 100644 --- a/server/api/registry/dependents/[...pkg].get.ts +++ b/server/api/registry/dependents/[...pkg].get.ts @@ -1,4 +1,4 @@ -import { CACHE_MAX_AGE_FIVE_MINUTES, NPM_REGISTRY } from '#shared/utils/constants' +import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' const NPM_SEARCH_BASE = 'https://registry.npmjs.org/-/v1/search' From b8dd395cb57a0414d38bb88deb8a8f06a8bffa7d Mon Sep 17 00:00:00 2001 From: Alexander Wondwossen Date: Sun, 22 Mar 2026 14:51:12 -0400 Subject: [PATCH 4/7] test: add dependents API response mapping unit tests --- test/unit/app/server/dependents.spec.ts | 106 ++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 test/unit/app/server/dependents.spec.ts diff --git a/test/unit/app/server/dependents.spec.ts b/test/unit/app/server/dependents.spec.ts new file mode 100644 index 0000000000..075952fb64 --- /dev/null +++ b/test/unit/app/server/dependents.spec.ts @@ -0,0 +1,106 @@ +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) + }) +}) From f6f50c05228fa16f08c3f843fa1a161c986a2b64 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:52:50 +0000 Subject: [PATCH 5/7] [autofix.ci] apply automated fixes --- app/pages/package/[[org]]/[name]/dependents.vue | 10 ++-------- test/unit/app/server/dependents.spec.ts | 7 ++++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/pages/package/[[org]]/[name]/dependents.vue b/app/pages/package/[[org]]/[name]/dependents.vue index edf77e8a9f..38254b340a 100644 --- a/app/pages/package/[[org]]/[name]/dependents.vue +++ b/app/pages/package/[[org]]/[name]/dependents.vue @@ -171,14 +171,8 @@ useSeoMeta({ > {{ $t('common.previous') }} - - {{ page + 1 }} / {{ totalPages }} - - + {{ page + 1 }} / {{ totalPages }} + {{ $t('common.next') }} diff --git a/test/unit/app/server/dependents.spec.ts b/test/unit/app/server/dependents.spec.ts index 075952fb64..146b72ffab 100644 --- a/test/unit/app/server/dependents.spec.ts +++ b/test/unit/app/server/dependents.spec.ts @@ -43,7 +43,12 @@ 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' }, + package: { + name: 'some-lib', + version: '1.2.3', + description: 'A library', + date: '2024-01-01', + }, score: { final: 0.95 }, searchScore: 100, }, From c260e85a275195bd1f8faafbca22c922456327f9 Mon Sep 17 00:00:00 2001 From: Alexander Wondwossen Date: Sun, 22 Mar 2026 15:32:23 -0400 Subject: [PATCH 6/7] fix: add missing dependents i18n keys and regenerate schema --- i18n/locales/en.json | 12 +++++++++++- i18n/schema.json | 45 +++++++++++++++++++++----------------------- 2 files changed, 32 insertions(+), 25 deletions(-) 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 008372dc39..425e8d2a94 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1411,27 +1411,6 @@ }, "additionalProperties": false }, - "dependents": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "subtitle": { - "type": "string" - }, - "count": { - "type": "string" - }, - "error": { - "type": "string" - }, - "none": { - "type": "string" - } - }, - "additionalProperties": false - }, "maintainers": { "type": "object", "properties": { @@ -1938,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 @@ -2524,9 +2524,6 @@ "back_to_package": { "type": "string" }, - "toggle_word_wrap": { - "type": "string" - }, "table": { "type": "object", "properties": { From 23ac526892a8a8b607632908f2fc9b9eb565e97b Mon Sep 17 00:00:00 2001 From: Alexander Wondwossen Date: Sun, 22 Mar 2026 15:54:17 -0400 Subject: [PATCH 7/7] fix: resolve TypeScript noUncheckedIndexedAccess error in dependents test --- test/unit/app/server/dependents.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/app/server/dependents.spec.ts b/test/unit/app/server/dependents.spec.ts index 146b72ffab..5132f7d5a6 100644 --- a/test/unit/app/server/dependents.spec.ts +++ b/test/unit/app/server/dependents.spec.ts @@ -79,8 +79,8 @@ describe('dependents API response mapping', () => { ] const result = mapNpmSearchResponse(objects, 1, 0, 20) - expect(result.packages[0].description).toBeNull() - expect(result.packages[0].date).toBeNull() + expect(result.packages[0]!.description).toBeNull() + expect(result.packages[0]!.date).toBeNull() }) it('returns empty packages array when objects is empty', () => {