From c833645d8937254bad08876fbdb16e25b431d8c2 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:46:38 -0500 Subject: [PATCH 1/4] feat(social): find the collections that include a package or collection GET /api/social/collections/containing pages Constellation backlinks on items[].subjectRef, for a package URL or a collection AT-URI, and limit=0 returns only the total. A collection view now carries how many other collections include it. --- app/storybook/mocks/handlers/collections.ts | 1 + .../api/social/collections/containing.get.ts | 23 +++++++ server/utils/atproto/utils/collections.ts | 60 ++++++++++++++++++- shared/types/social.ts | 4 +- shared/utils/constellation.ts | 4 +- test/unit/server/utils/collections.spec.ts | 39 +++++++++++- 6 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 server/api/social/collections/containing.get.ts diff --git a/app/storybook/mocks/handlers/collections.ts b/app/storybook/mocks/handlers/collections.ts index 9af3de28d1..d207ce22c8 100644 --- a/app/storybook/mocks/handlers/collections.ts +++ b/app/storybook/mocks/handlers/collections.ts @@ -42,6 +42,7 @@ export const mockCollection: CollectionView = { ...mockCollectionSummary, likes: { total: 12, viewerHas: false }, follows: { total: 5, viewerHas: false }, + includedInCount: 2, items: [ { kind: 'package', diff --git a/server/api/social/collections/containing.get.ts b/server/api/social/collections/containing.get.ts new file mode 100644 index 0000000000..e873140aa2 --- /dev/null +++ b/server/api/social/collections/containing.get.ts @@ -0,0 +1,23 @@ +/** + * GET /api/social/collections/containing?subject=&cursor=&limit= + * + * Collections whose items include `subject`, a package URL or a collection AT-URI. + * `limit=0` returns only the total. + */ +export default defineEventHandler(async event => { + const subject = getQuery(event).subject + if (typeof subject !== 'string' || !parseSubjectRef(subject)) { + throw createError({ status: 400, message: 'Invalid subject' }) + } + + try { + const utils = new CollectionsUtils() + if (getQuery(event).limit === '0') { + return { total: await utils.countCollectionsContaining(subject), items: [] } + } + const { cursor, limit } = getPaginationQuery(event) + return await utils.getCollectionsContaining(subject, limit, cursor) + } catch (error: unknown) { + handleApiError(error, { statusCode: 502, message: 'Failed to get containing collections' }) + } +}) diff --git a/server/utils/atproto/utils/collections.ts b/server/utils/atproto/utils/collections.ts index 178f91b95f..8eb38eb20a 100644 --- a/server/utils/atproto/utils/collections.ts +++ b/server/utils/atproto/utils/collections.ts @@ -17,6 +17,7 @@ import type { Backlink, Constellation } from '#shared/utils/constellation' const HEADERS = { 'User-Agent': 'npmx' } const COLLECTION_NSID = dev.npmx.collection.$nsid +const ITEMS_PATH = 'items[].subjectRef' // Records that point at a collection and are counted once per account. const REACTIONS = { @@ -31,6 +32,8 @@ const CACHE_REACTION_TOTAL_KEY = (reaction: Reaction, uri: string) => `${CACHE_PREFIX}${uri}:${reaction}:total` const CACHE_VIEWER_REACTION_KEY = (reaction: Reaction, uri: string, did: string) => `${CACHE_PREFIX}${uri}:${reaction}:${did}` +const CACHE_CONTAINING_COUNT_KEY = (subjectRef: string) => + `${CACHE_PREFIX}${subjectRef}:containing:total` const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_MINUTE * 5 // The owner's PDS is whatever their DID doc says, so it may be slow or hostile. @@ -212,7 +215,7 @@ export class CollectionsUtils { const stored = await this.getStoredRecord(miniDoc, rkey) if (!stored) return null - const [items, likes, follows] = await Promise.all([ + const [items, likes, follows, includedInCount] = await Promise.all([ mapWithConcurrency( stored.value.items, item => this.resolveItem(item.subjectRef, item.note), @@ -220,8 +223,9 @@ export class CollectionsUtils { ), this.getReaction('like', stored.uri, viewerDid), this.getReaction('follow', stored.uri, viewerDid), + this.countCollectionsContaining(stored.uri), ]) - return { ...toSummary(miniDoc, rkey, stored), items, likes, follows } + return { ...toSummary(miniDoc, rkey, stored), items, likes, follows, includedInCount } } async getReaction(reaction: Reaction, uri: string, viewerDid?: string): Promise { @@ -376,6 +380,58 @@ export class CollectionsUtils { } } + /** + * Collections whose items reference `subjectRef` (a package URL or a collection AT-URI). + * `total` is Constellation's raw count, so it may include invalid collections. + */ + async getCollectionsContaining( + subjectRef: string, + limit: number, + cursor?: string, + ): Promise> { + const { data } = await this.deps.constellation.getBackLinks( + subjectRef, + COLLECTION_NSID, + ITEMS_PATH, + limit, + cursor, + false, + [], + 0, + ) + return { + cursor: data.cursor, + total: data.total, + items: await this.hydrateBacklinks(data.records), + } + } + + countCollectionsContaining(subjectRef: string): Promise { + return this.cached(CACHE_CONTAINING_COUNT_KEY(subjectRef), async () => { + const { data } = await this.deps.constellation.getBackLinks( + subjectRef, + COLLECTION_NSID, + ITEMS_PATH, + 1, + undefined, + false, + [], + 0, + ) + return data.total + }) + } + + private hydrateBacklinks(backlinks: Backlink[]): Promise { + const unique = new Map( + backlinks.map(link => [COLLECTION_SUBJECT_REF(link.did, link.rkey), link]), + ).values() + return this.hydrate([...unique], async (miniDoc, link) => { + const stored = await this.getStoredRecord(miniDoc, link.rkey) + return stored && toSummary(miniDoc, link.rkey, stored) + }) + } + /** * Turns entries into summaries via `build`, dropping any entry whose author no longer resolves * (deleted, deactivated) or that `build` rejects. diff --git a/shared/types/social.ts b/shared/types/social.ts index 9dcade25f4..ae724c0597 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -26,7 +26,7 @@ export type LikesLeaderboardEntry = { homepageLogoHeight: number | null } -/** A collection as listed (profile, browse), without resolved items */ +/** A collection as listed (profile, package page, browse), without resolved items */ export type CollectionSummary = { uri: string cid: string @@ -81,6 +81,8 @@ export type CollectionView = CollectionSummary & { items: CollectionItemView[] likes: ReactionState follows: ReactionState + // How many other collections include this one + includedInCount: number } /** diff --git a/shared/utils/constellation.ts b/shared/utils/constellation.ts index 01396e4222..e2a48bed57 100644 --- a/shared/utils/constellation.ts +++ b/shared/utils/constellation.ts @@ -73,7 +73,7 @@ export class Constellation { // constellation API endpoint, and type casting the response. const source = encodeURIComponent(`${collection}:${recordPath}`) let urlToCall = `https://${CONSTELLATION_HOST}/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(subject)}&source=${source}&limit=${limit}` - if (cursor) urlToCall += `&cursor=${cursor}` + if (cursor) urlToCall += `&cursor=${encodeURIComponent(cursor)}` if (reverse) urlToCall += '&reverse=true' filterByDids.forEach(did => (urlToCall += `&did=${did}`)) @@ -98,7 +98,7 @@ export class Constellation { ttl: number | undefined = undefined, ) { let urlToCall = `https://${CONSTELLATION_HOST}/links/distinct-dids?target=${encodeURIComponent(target)}&collection=${collection}&path=${recordPath}&limit=${limit}` - if (cursor) urlToCall += `&cursor=${cursor}` + if (cursor) urlToCall += `&cursor=${encodeURIComponent(cursor)}` return await this.cachedFetch(urlToCall, { headers: HEADERS }, ttl) } diff --git a/test/unit/server/utils/collections.spec.ts b/test/unit/server/utils/collections.spec.ts index 0a85e2d4d7..8e4044d511 100644 --- a/test/unit/server/utils/collections.spec.ts +++ b/test/unit/server/utils/collections.spec.ts @@ -162,7 +162,7 @@ describe('CollectionsUtils.getCollection', () => { ]) }) - it('aggregates like and follow stats', async () => { + it('aggregates like, follow and included-in stats', async () => { const { utils, constellation } = setup({ [key(ALICE, RKEY_A)]: record('A', []) }) constellation.getLinksDistinctDids.mockImplementation(async (...args: unknown[]) => ({ data: { @@ -191,6 +191,7 @@ describe('CollectionsUtils.getCollection', () => { expect(view).toMatchObject({ likes: { total: 7, viewerHas: true }, follows: { total: 2, viewerHas: false }, + includedInCount: 3, }) expect(constellation.getLinksDistinctDids).toHaveBeenCalledWith( COLLECTION_SUBJECT_REF(ALICE, RKEY_A), @@ -294,6 +295,42 @@ describe('CollectionsUtils.listFollowedCollections', () => { }) }) +describe('CollectionsUtils.getCollectionsContaining', () => { + it('hydrates backlinks, dedupes, and drops missing collections', async () => { + const { utils, constellation } = setup({ + [key(ALICE, RKEY_A)]: record('Alice', [PACKAGE_SUBJECT_REF('zod')]), + }) + constellation.getBackLinks.mockResolvedValue({ + data: { + total: 3, + cursor: 'next', + records: [ + { did: ALICE, collection: 'dev.npmx.collection', rkey: RKEY_A }, + { did: BOB, collection: 'dev.npmx.collection', rkey: RKEY_B }, + ], + }, + isStale: false, + cachedAt: null, + }) + + const result = await utils.getCollectionsContaining(PACKAGE_SUBJECT_REF('zod'), 10) + + expect(result.total).toBe(3) + expect(result.cursor).toBe('next') + expect(result.items.map(item => item.name)).toEqual(['Alice']) + expect(constellation.getBackLinks).toHaveBeenCalledWith( + PACKAGE_SUBJECT_REF('zod'), + 'dev.npmx.collection', + 'items[].subjectRef', + 10, + undefined, + false, + [], + 0, + ) + }) +}) + describe('CollectionsUtils.listUserCollections', () => { it('returns summaries with the record key', async () => { const { utils } = setup( From 65a6ef67230ba588622130c88b38a505d8db32b0 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:46:58 -0500 Subject: [PATCH 2/4] feat(social): hide collections from accounts labeled by Bluesky moderation Once collections show up on package pages, anyone can put spam in front of a package's visitors. We don't run a labeler, so accounts that Bluesky's moderation service labels spam, !hide or !takedown are hidden everywhere: their collections, the collections that include a package, and as entries included in other collections. Lookups are batched and cached, and fail open. --- app/components/Collection/ItemList.stories.ts | 20 ++- app/components/Collection/ItemList.vue | 10 +- app/storybook/mocks/handlers/collections.ts | 6 + i18n/locales/en.json | 3 +- i18n/locales/fr-FR.json | 3 +- i18n/schema.json | 3 + server/utils/atproto/utils/collections.ts | 30 +++-- server/utils/atproto/utils/moderation.ts | 93 +++++++++++++ shared/types/social.ts | 2 +- test/nuxt/a11y.spec.ts | 4 +- test/unit/server/utils/collections.spec.ts | 122 ++++++++++++++++-- 11 files changed, 257 insertions(+), 39 deletions(-) create mode 100644 server/utils/atproto/utils/moderation.ts diff --git a/app/components/Collection/ItemList.stories.ts b/app/components/Collection/ItemList.stories.ts index 1569dc6e1b..9c88ddcf42 100644 --- a/app/components/Collection/ItemList.stories.ts +++ b/app/components/Collection/ItemList.stories.ts @@ -12,7 +12,7 @@ const meta = { export default meta type Story = StoryObj -/** Packages with notes, a package missing from the registry, and a nested collection. */ +/** Packages with notes, a package missing from the registry, a nested collection, and a hidden one. */ export const Default: Story = {} /** Packages shown as cards once their metadata has loaded. */ @@ -20,17 +20,15 @@ export const WithPackageCards: Story = { args: { packages: mockPackageCards }, } -/** An included collection that can't be shown. */ -export const UnavailableCollection: Story = { +/** Every reason an included collection can't be shown. */ +export const UnavailableCollections: Story = { args: { - items: [ - { - kind: 'collection', - subjectRef: 'at://did:plc:x/dev.npmx.collection/3m5k5baifas2z', - collection: null, - unavailableReason: 'unresolvable', - }, - ], + items: (['unresolvable', 'hidden'] as const).map(reason => ({ + kind: 'collection' as const, + subjectRef: `at://did:plc:x/dev.npmx.collection/${reason}`, + collection: null, + unavailableReason: reason, + })), }, } diff --git a/app/components/Collection/ItemList.vue b/app/components/Collection/ItemList.vue index eb59e397cf..9e2de503ac 100644 --- a/app/components/Collection/ItemList.vue +++ b/app/components/Collection/ItemList.vue @@ -1,6 +1,6 @@ + + {{ + $t( + 'collections.in_collections', + { count: compactNumberFormatter.format(collection.includedInCount) }, + collection.includedInCount, + ) + }} + diff --git a/app/pages/collections/index.vue b/app/pages/collections/index.vue new file mode 100644 index 0000000000..58dbdb0b82 --- /dev/null +++ b/app/pages/collections/index.vue @@ -0,0 +1,82 @@ + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 0bd5d0e26a..3b9debdd56 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -482,6 +482,9 @@ "delete_title": "Delete this collection?", "delete_body": "This can't be undone.", "delete_error": "Couldn't delete the collection. Please try again.", + "in_collections": "In {count} collection | In {count} collections", + "containing_package_title": "Collections with {name}", + "including_title": "Collections that include this one", "includes": "Includes", "package_unresolved": "Not found on the npm registry", "unknown_item": "Unrecognized item", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 823f0a1874..b621ba4d29 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -388,6 +388,9 @@ "delete_title": "Supprimer cette collection ?", "delete_body": "Cette action est irréversible.", "delete_error": "Impossible de supprimer la collection. Veuillez réessayer.", + "in_collections": "Dans {count} collection | Dans {count} collections", + "containing_package_title": "Collections avec {name}", + "including_title": "Collections qui incluent celle-ci", "includes": "Inclut", "package_unresolved": "Introuvable sur le registre npm", "unknown_item": "Élément non reconnu", diff --git a/i18n/schema.json b/i18n/schema.json index ed54fbb3f3..b47acf5b22 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1450,6 +1450,15 @@ "delete_error": { "type": "string" }, + "in_collections": { + "type": "string" + }, + "containing_package_title": { + "type": "string" + }, + "including_title": { + "type": "string" + }, "includes": { "type": "string" }, diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 63920603e3..983633414c 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -370,6 +370,7 @@ import CollectionItemList from '~/components/Collection/ItemList.vue' import CollectionLikeButton from '~/components/Collection/LikeButton.vue' import CollectionFollowButton from '~/components/Collection/FollowButton.vue' import CollectionYourData from '~/components/Collection/YourData.vue' +import PackageCollectionsCount from '~/components/Package/CollectionsCount.vue' import type { CollectionItemView, CollectionSummary } from '#shared/types/social' import SizeIncrease from '~/components/Package/SizeIncrease.vue' import SizeDecrease from '~/components/Package/SizeDecrease.vue' @@ -1443,6 +1444,18 @@ describe('component accessibility audits', () => { const results = await runAxe(component) expect(results.violations).toEqual([]) }) + + it('PackageCollectionsCount should have no accessibility violations', async () => { + registerEndpoint('/api/social/collections/containing', () => ({ total: 3, items: [] })) + const component = await mountSuspended(PackageCollectionsCount, { + props: { packageName: 'zod' }, + }) + await vi.waitFor(() => { + expect(component.text()).toContain('3') + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) }) describe('PackageLikeCard', () => { From 5370c93e4f71a5a0d337608dee4632ba8eeea778 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sun, 8 Nov 2026 18:47:23 -0500 Subject: [PATCH 4/4] docs: document finding collections that include a package --- docs/content/2.guide/1.features.md | 1 + docs/content/2.guide/3.url-structure.md | 27 ++++++++++---------- docs/content/4.integrations/2.collections.md | 6 +++-- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/content/2.guide/1.features.md b/docs/content/2.guide/1.features.md index 020a5a3764..491236608e 100644 --- a/docs/content/2.guide/1.features.md +++ b/docs/content/2.guide/1.features.md @@ -109,6 +109,7 @@ Collections are curated, ordered lists of npm packages, with an optional note on - **Include other collections** - Add a collection's link to another collection to show it as a linked entry with its author and size - **Duplicate** - Start a new collection from a copy of any collection - **Like and follow** - Like a collection to recommend it, or follow it to keep up with it. Your own collections count as followed +- **Find related collections** - Package pages and collection pages show how many collections include them ## Explore users and organizations diff --git a/docs/content/2.guide/3.url-structure.md b/docs/content/2.guide/3.url-structure.md index 3269730721..ae0d3e6f0a 100644 --- a/docs/content/2.guide/3.url-structure.md +++ b/docs/content/2.guide/3.url-structure.md @@ -40,19 +40,20 @@ npmx.dev also supports shorter, cleaner URLs: npmx.dev keeps package exploration views in routes or query parameters so you can copy and share the exact state you are looking at. -| View | Example | -| ------------------- | --------------------------------- | -| Search results | `/search?q=vue` | -| Package comparison | `/compare?packages=react,preact` | -| Tools | `/tools` | -| A collection | `/collections//` | -| Dependency stats | `/tools/deps-stats` | -| Source file or line | `/package-code/vue/v/3.5.27/...` | -| Generated docs | `/package-docs/ufo/v/1.6.3` | -| Version diff | `/diff/vue/v/3.5.26...3.5.27` | -| Changelog | `/package-changelog/vue/v/3.5.27` | -| Timeline | `/package-timeline/vue/v/3.5.27` | -| Stats | `/package-stats/vue/v/3.5.27` | +| View | Example | +| -------------------------- | --------------------------------- | +| Search results | `/search?q=vue` | +| Package comparison | `/compare?packages=react,preact` | +| Tools | `/tools` | +| A collection | `/collections//` | +| Collections with a package | `/collections?package=vue` | +| Dependency stats | `/tools/deps-stats` | +| Source file or line | `/package-code/vue/v/3.5.27/...` | +| Generated docs | `/package-docs/ufo/v/1.6.3` | +| Version diff | `/diff/vue/v/3.5.26...3.5.27` | +| Changelog | `/package-changelog/vue/v/3.5.27` | +| Timeline | `/package-timeline/vue/v/3.5.27` | +| Stats | `/package-stats/vue/v/3.5.27` | ## Understand URL limitations diff --git a/docs/content/4.integrations/2.collections.md b/docs/content/4.integrations/2.collections.md index b98250996f..c2664165c1 100644 --- a/docs/content/4.integrations/2.collections.md +++ b/docs/content/4.integrations/2.collections.md @@ -7,7 +7,7 @@ navigation: ## Collections live in your account -A collection is an ordered list of npm packages with notes, stored as a `dev.npmx.collection` record in your own atproto repository. npmx.dev is one way to make and view them, not the only one. Write a record with that shape from any app, script or PDS, and it shows up on your npmx.dev profile. +A collection is an ordered list of npm packages with notes, stored as a `dev.npmx.collection` record in your own atproto repository. npmx.dev is one way to make and view them, not the only one. Write a record with that shape from any app, script or PDS, and it shows up on npmx.dev: on your profile and on the pages of the packages it contains. Collections are public, like everything else in an atproto repository. @@ -122,4 +122,6 @@ To edit a collection, use `com.atproto.repo.putRecord` with the same record key, ## When it shows up - **Your profile** reads straight from your PDS, so it updates right away. **The collection page** does too for changes made on npmx.dev; changes from other apps can take up to 5 minutes. -- **Like counts** come from [Constellation](https://constellation.microcosm.blue/), usually within seconds. +- **Package pages** ("in N collections") and like counts come from [Constellation](https://constellation.microcosm.blue/), usually within seconds. + +Collections from accounts that Bluesky's moderation service has labeled as spam or hidden don't appear on npmx.dev.