diff --git a/README.md b/README.md index 05efb54876..14b6576b1d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | ✅ | ❌ | @@ -119,7 +120,6 @@ npmx.dev supports npm permalinks – just replace `npmjs.com` with `npmx.dev #### Not yet supported - `/package//access` – package access settings -- `/package//dependents` – dependent packages list - `/settings/*` – account settings pages ### Simpler URLs 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..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..4255496862 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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", diff --git a/i18n/schema.json b/i18n/schema.json index 885bd6eb5e..c80fd13ebd 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -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 diff --git a/server/api/registry/dependents/[...pkg].get.ts b/server/api/registry/dependents/[...pkg].get.ts new file mode 100644 index 0000000000..208a72be0e --- /dev/null +++ b/server/api/registry/dependents/[...pkg].get.ts @@ -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) + + const params = new URLSearchParams({ + text: `dependencies:${packageName}`, + size: String(size), + from: String(page * size), + }) + + const response = await $fetch(`${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, + })), + } + }, + { + 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}` + }, + }, +)