From 0c88df0ae070e7bcaa96e85ade9b9b3f66f17b59 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 7 Nov 2026 09:11:52 -0500 Subject: [PATCH 1/6] feat: show number of social mentions on package page Constellation indexes backlinks and their totals, so we can cheaply fetch the number of mentions on Blueksy for a given package. This adds the social mentions count next to the likes count. It counts the number of mentions of the package URL (both npmjs.com and npmx.dev). It links to a Bluesky search. It is rendered only lazily on the client. It's cached on the server with SWR for 1 hour and allows browser to do the same. --- app/components/Package/Header.vue | 1 + app/components/Package/SocialMentions.vue | 54 ++++++ i18n/locales/en.json | 3 + i18n/locales/fr-FR.json | 3 + i18n/schema.json | 9 + server/api/social/mentions/[...pkg].get.ts | 40 +++++ server/utils/atproto/utils/social-mentions.ts | 100 +++++++++++ shared/types/social.ts | 7 + test/nuxt/a11y.spec.ts | 21 +++ .../unit/server/utils/social-mentions.spec.ts | 165 ++++++++++++++++++ 10 files changed, 403 insertions(+) create mode 100644 app/components/Package/SocialMentions.vue create mode 100644 server/api/social/mentions/[...pkg].get.ts create mode 100644 server/utils/atproto/utils/social-mentions.ts create mode 100644 test/unit/server/utils/social-mentions.spec.ts diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue index 256c5d6f4f..5980242601 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -250,6 +250,7 @@ useShortcuts({
+ +import type { PackageSocialMentions } from '#shared/types/social' + +const props = defineProps<{ + packageName: string +}>() + +const compactNumberFormatter = useCompactNumberFormatter() + +const { data: mentionsData, execute: fetchMentions } = useLazyFetch( + () => `/api/social/mentions/${props.packageName}`, + { + default: () => ({ totalMentions: 0 }), + server: false, + immediate: false, + }, +) + +// Client-only: this decorative stat should never block SSR. +if (import.meta.client) { + watch( + () => props.packageName, + packageName => { + if (packageName) fetchMentions() + }, + { immediate: true }, + ) +} + +const totalMentions = computed(() => mentionsData.value?.totalMentions ?? 0) +// Bluesky search has no OR, so search by package name to cover both npmx.dev and npmjs.com links. +const searchUrl = computed( + () => `https://bsky.app/search?q=${encodeURIComponent(`"${props.packageName}"`)}`, +) +const label = computed(() => + $t( + 'package.social_mentions.label', + { count: compactNumberFormatter.value.format(totalMentions.value) }, + totalMentions.value, + ), +) + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index babf83d554..86aa8491cd 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -555,6 +555,9 @@ "top_rank_label": "#{rank}", "top_rank_link_label": "View likes leaderboard. This package is ranked #{rank}." }, + "social_mentions": { + "label": "{count} mention | {count} mentions" + }, "docs": { "contents": "Contents", "default_not_available": "Docs are not available for this version.", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 4a1c6efd80..b9c2e22401 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -453,6 +453,9 @@ "top_rank_label": "n°{rank}", "top_rank_link_label": "Voir le classement des likes. Ce paquet est classé n°{rank}." }, + "social_mentions": { + "label": "{count} mention | {count} mentions" + }, "docs": { "contents": "Sommaire", "default_not_available": "La documentation n'est pas disponible pour cette version.", diff --git a/i18n/schema.json b/i18n/schema.json index cb101c8a59..db1685dc2e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1669,6 +1669,15 @@ }, "additionalProperties": false }, + "social_mentions": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "additionalProperties": false + }, "docs": { "type": "object", "properties": { diff --git a/server/api/social/mentions/[...pkg].get.ts b/server/api/social/mentions/[...pkg].get.ts new file mode 100644 index 0000000000..c2b804c905 --- /dev/null +++ b/server/api/social/mentions/[...pkg].get.ts @@ -0,0 +1,40 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' + +/** + * GET /api/social/mentions/:name + * + * Gets the approximate number of social posts that mention a npm package + */ +export default defineEventHandler(async event => { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + const { rawPackageName } = parsePackageParams(pkgParamSegments) + + if (!rawPackageName) { + throw createError({ + status: 400, + message: 'package name not provided', + }) + } + + try { + const { packageName } = v.parse(PackageRouteParamsSchema, { + packageName: decodeURIComponent(rawPackageName), + }) + + const mentionsUtil = new PackageSocialMentionsUtils() + const mentions = await mentionsUtil.getMentions(packageName) + + setHeader( + event, + 'Cache-Control', + `public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`, + ) + return mentions + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to get social mentions', + }) + } +}) diff --git a/server/utils/atproto/utils/social-mentions.ts b/server/utils/atproto/utils/social-mentions.ts new file mode 100644 index 0000000000..c7cd9f17a4 --- /dev/null +++ b/server/utils/atproto/utils/social-mentions.ts @@ -0,0 +1,100 @@ +import type { Backlink } from '#shared/utils/constellation' + +const CACHE_PREFIX = 'atproto-social-mentions:' +const CACHE_PACKAGE_TOTAL_KEY = (packageName: string) => `${CACHE_PREFIX}${packageName}:total` + +const CACHE_MAX_AGE = CACHE_MAX_AGE_ONE_HOUR + +const BLUESKY_POST_COLLECTION = 'app.bsky.feed.post' +// getBackLinks expects paths without the leading dot that /links/all returns. +const BLUESKY_LINK_PATHS = [ + 'embed.external.uri', + 'facets[].features[app.bsky.richtext.facet#link].uri', +] as const + +const NPMJS_PACKAGE_URL = (packageName: string) => `https://www.npmjs.com/package/${packageName}` + +/** The subset of Constellation that PackageSocialMentionsUtils actually needs. */ +export type ConstellationMentions = Pick + +/** + * Counts unique Bluesky posts that link to an npm package. + * + * Both npmx.dev and npmjs.com package URLs are counted because most posts link + * to the npmjs.com page, which is usually the larger number. Posts that link + * multiple URLs, or both embed and facet-link one, are only counted once. + */ +export class PackageSocialMentionsUtils { + private readonly constellation: ConstellationMentions + private readonly cache: CacheAdapter + + constructor(deps?: { constellation?: ConstellationMentions; cache?: CacheAdapter }) { + this.constellation = + deps?.constellation ?? + new Constellation( + // Passes in a fetch wrapped as CachedFetch because we're already caching here + async ( + url: string, + options: Parameters[1] = {}, + _ttl?: number, + ): Promise> => { + const data = (await $fetch(url, options)) as T + return { data, isStale: false, cachedAt: null } + }, + ) + this.cache = deps?.cache ?? getCacheAdapter('generic') + } + + private async constellationPostKeys(target: string, recordPath: string) { + const postKeys = new Set() + let cursor: string | undefined + + do { + const { data } = await this.constellation.getBackLinks( + target, + BLUESKY_POST_COLLECTION, + recordPath, + 100, + cursor, + false, + [], + 0, + ) + + for (const record of data.records) { + postKeys.add(postKey(record)) + } + cursor = data.cursor + } while (cursor) + + return postKeys + } + + async getMentions(packageName: string): Promise { + const cacheKey = CACHE_PACKAGE_TOTAL_KEY(packageName) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const targets = [PACKAGE_SUBJECT_REF(packageName), NPMJS_PACKAGE_URL(packageName)] + const postKeySets = await Promise.all( + targets.flatMap(target => + BLUESKY_LINK_PATHS.map(recordPath => this.constellationPostKeys(target, recordPath)), + ), + ) + + const uniquePosts = new Set() + for (const postKeys of postKeySets) { + for (const key of postKeys) uniquePosts.add(key) + } + + const result: PackageSocialMentions = { + totalMentions: uniquePosts.size, + } + await this.cache.set(cacheKey, result, CACHE_MAX_AGE) + return result + } +} + +function postKey(record: Backlink): string { + return `${record.did}:${record.collection}:${record.rkey}` +} diff --git a/shared/types/social.ts b/shared/types/social.ts index b74e6595e8..4322ac29dd 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -10,6 +10,13 @@ export type PackageLikes = { topLikedRank: number | null } +/** + * Approximate number of social posts mentioning a npm package + */ +export type PackageSocialMentions = { + totalMentions: number +} + export type LikesLeaderboardEntry = { rank: number packageName: string diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 742cdbc569..615f4d2f75 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -288,6 +288,7 @@ import { PackagePlaygrounds, PackageReplacement, PackageSidebar, + PackageSocialMentions, PackageSkeleton, PackageSkillsCard, PackageTable, @@ -947,6 +948,26 @@ describe('component accessibility audits', () => { }) }) + describe('PackageSocialMentions', () => { + it('should have no accessibility violations', async () => { + registerEndpoint('/api/social/mentions/vue', () => ({ totalMentions: 12 })) + + const component = await mountSuspended(PackageSocialMentions, { + props: { packageName: 'vue' }, + }) + + await vi.waitFor( + () => { + expect(component.text()).toContain('12 mentions') + }, + { timeout: 3000 }, + ) + + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('LikesLeaderboardPage', () => { it('should have no accessibility violations when data is available', async () => { clearNuxtData() diff --git a/test/unit/server/utils/social-mentions.spec.ts b/test/unit/server/utils/social-mentions.spec.ts new file mode 100644 index 0000000000..6457f48513 --- /dev/null +++ b/test/unit/server/utils/social-mentions.spec.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi, type Mocked } from 'vite-plus/test' +import type { ConstellationMentions } from '#server/utils/atproto/utils/social-mentions' +import type { CacheAdapter } from '#server/utils/cache/shared' + +vi.stubGlobal('CACHE_MAX_AGE_ONE_HOUR', 60 * 60) +vi.stubGlobal('PACKAGE_SUBJECT_REF', (pkg: string) => `https://npmx.dev/package/${pkg}`) +vi.stubGlobal('$fetch', vi.fn()) +vi.stubGlobal('Constellation', vi.fn()) +vi.stubGlobal('getCacheAdapter', vi.fn()) + +const { PackageSocialMentionsUtils } = await import('#server/utils/atproto/utils/social-mentions') + +type TestBacklink = { + did: string + collection: string + rkey: string +} + +function post(did: string, rkey: string): TestBacklink { + return { did, collection: 'app.bsky.feed.post', rkey } +} + +function makeBacklinksPage(records: TestBacklink[], cursor?: string) { + return { + data: { + records, + total: records.length, + cursor, + }, + isStale: false, + cachedAt: null, + } +} + +describe('PackageSocialMentionsUtils.getMentions', () => { + const mockConstellation: Mocked = { + getBackLinks: vi.fn(), + } + // vi.fn() can't represent CacheAdapter's generic get/set signatures, + // so we assert once here and get full type-safety everywhere else. + const mockCache = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + } as unknown as Mocked + + beforeEach(() => { + vi.clearAllMocks() + mockCache.get.mockResolvedValue(undefined) + mockCache.set.mockResolvedValue(undefined) + mockConstellation.getBackLinks.mockResolvedValue(makeBacklinksPage([])) + }) + + it('counts unique posts across npmx, npmjs, embeds and facet links', async () => { + const npmxEmbed = 'https://npmx.dev/package/zod|embed.external.uri' + const npmxFacet = + 'https://npmx.dev/package/zod|facets[].features[app.bsky.richtext.facet#link].uri' + const npmjsEmbed = 'https://www.npmjs.com/package/zod|embed.external.uri' + const npmjsFacet = + 'https://www.npmjs.com/package/zod|facets[].features[app.bsky.richtext.facet#link].uri' + + mockConstellation.getBackLinks.mockImplementation( + async ( + target: string, + _collection: string, + recordPath: string, + _limit: number = 100, + cursor?: string, + ) => { + const source = `${target}|${recordPath}` + + if (source === npmxEmbed) { + return cursor + ? makeBacklinksPage([post('did:plc:a', 'post-c')]) + : makeBacklinksPage([post('did:plc:a', 'post-a'), post('did:plc:b', 'post-b')], 'next') + } + if (source === npmxFacet) { + return makeBacklinksPage([post('did:plc:b', 'post-b'), post('did:plc:c', 'post-d')]) + } + if (source === npmjsEmbed) { + return makeBacklinksPage([post('did:plc:c', 'post-d'), post('did:plc:d', 'post-e')]) + } + if (source === npmjsFacet) { + return makeBacklinksPage([post('did:plc:d', 'post-e'), post('did:plc:e', 'post-f')]) + } + return makeBacklinksPage([]) + }, + ) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 6 }) + expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(5) + expect(mockConstellation.getBackLinks).toHaveBeenCalledWith( + 'https://npmx.dev/package/zod', + 'app.bsky.feed.post', + 'embed.external.uri', + 100, + undefined, + false, + [], + 0, + ) + expect(mockConstellation.getBackLinks).toHaveBeenCalledWith( + 'https://www.npmjs.com/package/zod', + 'app.bsky.feed.post', + 'facets[].features[app.bsky.richtext.facet#link].uri', + 100, + undefined, + false, + [], + 0, + ) + expect(mockConstellation.getBackLinks).toHaveBeenCalledWith( + 'https://npmx.dev/package/zod', + 'app.bsky.feed.post', + 'embed.external.uri', + 100, + 'next', + false, + [], + 0, + ) + expect(mockCache.set).toHaveBeenCalledWith( + expect.stringContaining('atproto-social-mentions:zod:total'), + { totalMentions: 6 }, + expect.any(Number), + ) + }) + + it('returns cached mentions without calling constellation', async () => { + mockCache.get.mockResolvedValueOnce({ totalMentions: 42 }) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('react') + + expect(mockConstellation.getBackLinks).not.toHaveBeenCalled() + expect(result).toEqual({ totalMentions: 42 }) + }) + + it('caches a zero result', async () => { + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('unmentioned-pkg') + + expect(result).toEqual({ totalMentions: 0 }) + expect(mockCache.set).toHaveBeenCalledWith( + expect.stringContaining('unmentioned-pkg:total'), + { totalMentions: 0 }, + expect.any(Number), + ) + }) +}) From b8ced27b0839a74662e031704e770b9e0c21a826 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 7 Nov 2026 09:25:31 -0500 Subject: [PATCH 2/6] fix: make btn less wide --- app/components/Package/SocialMentions.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/Package/SocialMentions.vue b/app/components/Package/SocialMentions.vue index 5d5936aa4d..555e0e08e9 100644 --- a/app/components/Package/SocialMentions.vue +++ b/app/components/Package/SocialMentions.vue @@ -47,7 +47,7 @@ const label = computed(() => classicon="i-lucide:message-circle" :to="searchUrl" data-testid="social-mentions" - class="min-w-44" + class="min-w-36" > {{ label }} From 9c4ae58edda49a701bf56d5cc1c3cff636d6a871 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Sat, 7 Nov 2026 11:06:40 -0500 Subject: [PATCH 3/6] fix: drop npmjs.com matching It isn't possible to have the button go to a search that includes both npmjs and npmx results, so the count wouldn't match the button... Meh. --- app/components/Package/SocialMentions.vue | 5 ++-- server/utils/atproto/utils/social-mentions.ts | 14 +++------- .../unit/server/utils/social-mentions.spec.ts | 28 ++++++------------- 3 files changed, 16 insertions(+), 31 deletions(-) diff --git a/app/components/Package/SocialMentions.vue b/app/components/Package/SocialMentions.vue index 555e0e08e9..41f718bbaa 100644 --- a/app/components/Package/SocialMentions.vue +++ b/app/components/Package/SocialMentions.vue @@ -1,5 +1,6 @@ diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index b9c2e22401..69c6e98c99 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -454,7 +454,7 @@ "top_rank_link_label": "Voir le classement des likes. Ce paquet est classé n°{rank}." }, "social_mentions": { - "label": "{count} mention | {count} mentions" + "label": "{count} mention | {count} mention | {count} mentions" }, "docs": { "contents": "Sommaire", diff --git a/server/api/social/like.post.ts b/server/api/social/like.post.ts index 3bdcf54105..68dbca78a4 100644 --- a/server/api/social/like.post.ts +++ b/server/api/social/like.post.ts @@ -26,7 +26,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => { return likesResult } - const subjectRef = PACKAGE_SUBJECT_REF(body.packageName) + const subjectRef = packageSubjectRef(body.packageName) const client = new Client(oAuthSession) const like = dev.npmx.feed.like.$build({ diff --git a/server/api/social/likes/[...pkg].get.ts b/server/api/social/likes/[...pkg].get.ts index 8db4c25e49..f084b6ed65 100644 --- a/server/api/social/likes/[...pkg].get.ts +++ b/server/api/social/likes/[...pkg].get.ts @@ -1,6 +1,6 @@ import * as v from 'valibot' import { PackageRouteParamsSchema } from '#shared/schemas/package' -import { PACKAGE_SUBJECT_REF } from '#shared/utils/constants' +import { packageSubjectRef } from '#shared/utils/constants' import { getTopLikedRank } from '#server/utils/likes-leaderboard' /** @@ -27,7 +27,7 @@ export default eventHandlerWithOAuthSession(async (event, oAuthSession) => { const likesUtil = new PackageLikesUtils() const [likes, topLikedRank] = await Promise.all([ likesUtil.getLikes(packageName, oAuthSession?.did.toString()), - getTopLikedRank(event, PACKAGE_SUBJECT_REF(packageName)), + getTopLikedRank(event, packageSubjectRef(packageName)), ]) return { diff --git a/server/api/social/mentions/[...pkg].get.ts b/server/api/social/mentions/[...pkg].get.ts index c2b804c905..12ae2d88c5 100644 --- a/server/api/social/mentions/[...pkg].get.ts +++ b/server/api/social/mentions/[...pkg].get.ts @@ -4,7 +4,7 @@ import { PackageRouteParamsSchema } from '#shared/schemas/package' /** * GET /api/social/mentions/:name * - * Gets the approximate number of social posts that mention a npm package + * Gets the number of unique social posts that mention a npm package, capped at 99+ */ export default defineEventHandler(async event => { const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] @@ -17,9 +17,19 @@ export default defineEventHandler(async event => { }) } + let decodedPackageName: string + try { + decodedPackageName = decodeURIComponent(rawPackageName) + } catch { + throw createError({ + status: 400, + message: 'Invalid package name encoding', + }) + } + try { const { packageName } = v.parse(PackageRouteParamsSchema, { - packageName: decodeURIComponent(rawPackageName), + packageName: decodedPackageName, }) const mentionsUtil = new PackageSocialMentionsUtils() diff --git a/server/utils/atproto/utils/likes.ts b/server/utils/atproto/utils/likes.ts index 54101e2b8e..18c280a661 100644 --- a/server/utils/atproto/utils/likes.ts +++ b/server/utils/atproto/utils/likes.ts @@ -115,7 +115,7 @@ export class PackageLikesUtils { async getLikes(packageName: string, usersDid?: string | undefined): Promise { //TODO: May need to do some clean up on the package name, and maybe even hash it? some of the characters may be a bit odd as keys const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const cachedLikes = await this.cache.get(totalLikesKey) let totalLikes = 0 @@ -161,7 +161,7 @@ export class PackageLikesUtils { if (cached !== undefined) { return cached } - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const userHasLiked = await this.constellationUserHasLiked(subjectRef, usersDid) await this.cache.set(CACHE_USER_LIKES_KEY(packageName, usersDid), userHasLiked, CACHE_MAX_AGE) @@ -181,7 +181,7 @@ export class PackageLikesUtils { atUri: string, ): Promise { const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const splitAtUri = atUri.replace('at://', '').split('/') const collection = splitAtUri[1] @@ -231,7 +231,7 @@ export class PackageLikesUtils { return backLink } - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const { data: userLikes } = await this.constellation.getBackLinks( subjectRef, likeNsid, @@ -265,7 +265,7 @@ export class PackageLikesUtils { */ async unlikeAPackageAndReturnLikes(packageName: string, usersDid: string): Promise { const totalLikesKey = CACHE_PACKAGE_TOTAL_KEY(packageName) - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) let totalLikes = await this.cache.get(totalLikesKey) if (!totalLikes) { @@ -319,7 +319,7 @@ export class PackageLikesUtils { const cached = await this.cache.get>(cacheKey) if (cached) return cached - const subjectRef = PACKAGE_SUBJECT_REF(packageName) + const subjectRef = packageSubjectRef(packageName) const allBacklinks: Backlink[] = [] let cursor: string | undefined diff --git a/server/utils/atproto/utils/social-mentions.ts b/server/utils/atproto/utils/social-mentions.ts index e8d6bf3f3e..70232d5f6f 100644 --- a/server/utils/atproto/utils/social-mentions.ts +++ b/server/utils/atproto/utils/social-mentions.ts @@ -1,4 +1,9 @@ import type { Backlink } from '#shared/utils/constellation' +import { + CACHE_MAX_AGE_ONE_HOUR, + packageSubjectRef, + SOCIAL_MENTIONS_MAX, +} from '#shared/utils/constants' const CACHE_PREFIX = 'atproto-social-mentions:' const cachePackageTotalKey = (packageName: string) => `${CACHE_PREFIX}${packageName}:total` @@ -18,7 +23,8 @@ export type ConstellationMentions = Pick /** * Counts unique Bluesky posts that link to an npmx.dev package. * - * Posts that both embed and facet-link the URL are only counted once. + * Posts that both embed and facet-link the URL are only counted once. At most + * one page of 100 records is read per source path; counts above 99 are capped. */ export class PackageSocialMentionsUtils { private readonly constellation: ConstellationMentions @@ -42,28 +48,21 @@ export class PackageSocialMentionsUtils { } private async constellationPostKeys(target: string, recordPath: string) { - const postKeys = new Set() - let cursor: string | undefined - - do { - const { data } = await this.constellation.getBackLinks( - target, - BLUESKY_POST_COLLECTION, - recordPath, - 100, - cursor, - false, - [], - 0, - ) - - for (const record of data.records) { - postKeys.add(postKey(record)) - } - cursor = data.cursor - } while (cursor) + const { data } = await this.constellation.getBackLinks( + target, + BLUESKY_POST_COLLECTION, + recordPath, + 100, + undefined, + false, + [], + 0, + ) - return postKeys + return { + postKeys: data.records.map(postKey), + hasMore: Boolean(data.cursor), + } } async getMentions(packageName: string): Promise { @@ -71,18 +70,22 @@ export class PackageSocialMentionsUtils { const cached = await this.cache.get(cacheKey) if (cached) return cached - const target = PACKAGE_SUBJECT_REF(packageName) - const postKeySets = await Promise.all( + const target = packageSubjectRef(packageName) + const results = await Promise.all( BLUESKY_LINK_PATHS.map(recordPath => this.constellationPostKeys(target, recordPath)), ) const uniquePosts = new Set() - for (const postKeys of postKeySets) { - for (const key of postKeys) uniquePosts.add(key) + let hasMore = false + for (const result of results) { + hasMore ||= result.hasMore + for (const key of result.postKeys) uniquePosts.add(key) } + const isCapped = hasMore || uniquePosts.size > SOCIAL_MENTIONS_MAX const result: PackageSocialMentions = { - totalMentions: uniquePosts.size, + totalMentions: isCapped ? SOCIAL_MENTIONS_MAX : uniquePosts.size, + isCapped, } await this.cache.set(cacheKey, result, CACHE_MAX_AGE) return result diff --git a/shared/types/social.ts b/shared/types/social.ts index 4322ac29dd..76aa3d0040 100644 --- a/shared/types/social.ts +++ b/shared/types/social.ts @@ -14,7 +14,10 @@ export type PackageLikes = { * Approximate number of social posts mentioning a npm package */ export type PackageSocialMentions = { + // Exact unique mention count up to 99, or 99 when capped totalMentions: number + // True when there are more than 99 unique mentions + isCapped: boolean } export type LikesLeaderboardEntry = { diff --git a/shared/utils/constants.ts b/shared/utils/constants.ts index 402eb41606..f979a6d074 100644 --- a/shared/utils/constants.ts +++ b/shared/utils/constants.ts @@ -58,10 +58,11 @@ export const ERROR_UNKNOWN_GIT_HOST = export const CONSTELLATION_HOST = 'constellation.microcosm.blue' export const SLINGSHOT_HOST = 'slingshot.microcosm.blue' +export const SOCIAL_MENTIONS_MAX = 99 + // ATProtocol // References used to link packages to things that are not inherently atproto -export const PACKAGE_SUBJECT_REF = (packageName: string) => - `https://npmx.dev/package/${packageName}` +export const packageSubjectRef = (packageName: string) => `https://npmx.dev/package/${packageName}` // OAuth scopes as we add new ones we need to check these on certain actions. If not redirect the user to login again to upgrade the scopes export const LIKES_SCOPE = `repo:${dev.npmx.feed.like.$nsid}` export const PROFILE_SCOPE = `repo:${dev.npmx.actor.profile.$nsid}` diff --git a/test/nuxt/components/Package/SocialMentions.spec.ts b/test/nuxt/components/Package/SocialMentions.spec.ts index d4735903cc..18c5988528 100644 --- a/test/nuxt/components/Package/SocialMentions.spec.ts +++ b/test/nuxt/components/Package/SocialMentions.spec.ts @@ -55,6 +55,22 @@ describe('PackageSocialMentions', () => { }) }) + it('renders 99+ for capped counts', async () => { + registerEndpoint('/api/social/mentions/popular-pkg', () => ({ + totalMentions: 99, + isCapped: true, + })) + + wrapper = await mountSuspended(SocialMentions, { + props: { packageName: 'popular-pkg' }, + attachTo: document.body, + }) + + await vi.waitFor(() => { + expect(wrapper?.text()).toContain('99+ mentions') + }) + }) + it('links to the exact npmx.dev package URL search', async () => { registerEndpoint('/api/social/mentions/vue', () => ({ totalMentions: 12 })) diff --git a/test/unit/server/utils/likes-evolution.spec.ts b/test/unit/server/utils/likes-evolution.spec.ts index 7221ac48b5..dd360ac406 100644 --- a/test/unit/server/utils/likes-evolution.spec.ts +++ b/test/unit/server/utils/likes-evolution.spec.ts @@ -4,7 +4,7 @@ import type { ConstellationLike } from '#server/utils/atproto/utils/likes' import type { CacheAdapter } from '#server/utils/cache/shared' vi.stubGlobal('CACHE_MAX_AGE_ONE_MINUTE', 60) -vi.stubGlobal('PACKAGE_SUBJECT_REF', (pkg: string) => `https://npmx.dev/package/${pkg}`) +vi.stubGlobal('packageSubjectRef', (pkg: string) => `https://npmx.dev/package/${pkg}`) vi.stubGlobal('$fetch', vi.fn()) vi.stubGlobal('Constellation', vi.fn()) vi.stubGlobal('getCacheAdapter', vi.fn()) diff --git a/test/unit/server/utils/social-mentions.spec.ts b/test/unit/server/utils/social-mentions.spec.ts index 0052ab3c5b..510b770b05 100644 --- a/test/unit/server/utils/social-mentions.spec.ts +++ b/test/unit/server/utils/social-mentions.spec.ts @@ -2,8 +2,6 @@ import { beforeEach, describe, expect, it, vi, type Mocked } from 'vite-plus/tes import type { ConstellationMentions } from '#server/utils/atproto/utils/social-mentions' import type { CacheAdapter } from '#server/utils/cache/shared' -vi.stubGlobal('CACHE_MAX_AGE_ONE_HOUR', 60 * 60) -vi.stubGlobal('PACKAGE_SUBJECT_REF', (pkg: string) => `https://npmx.dev/package/${pkg}`) vi.stubGlobal('$fetch', vi.fn()) vi.stubGlobal('Constellation', vi.fn()) vi.stubGlobal('getCacheAdapter', vi.fn()) @@ -51,31 +49,14 @@ describe('PackageSocialMentionsUtils.getMentions', () => { mockConstellation.getBackLinks.mockResolvedValue(makeBacklinksPage([])) }) - it('counts unique posts across embed and facet links', async () => { - const embed = 'https://npmx.dev/package/zod|embed.external.uri' - const facet = 'https://npmx.dev/package/zod|facets[].features[app.bsky.richtext.facet#link].uri' - - mockConstellation.getBackLinks.mockImplementation( - async ( - target: string, - _collection: string, - recordPath: string, - _limit: number = 100, - cursor?: string, - ) => { - const source = `${target}|${recordPath}` - - if (source === embed) { - return cursor - ? makeBacklinksPage([post('did:plc:a', 'post-c')]) - : makeBacklinksPage([post('did:plc:a', 'post-a'), post('did:plc:b', 'post-b')], 'next') - } - if (source === facet) { - return makeBacklinksPage([post('did:plc:b', 'post-b'), post('did:plc:c', 'post-d')]) - } - return makeBacklinksPage([]) - }, - ) + it('deduplicates posts across embed and facet links', async () => { + mockConstellation.getBackLinks + .mockResolvedValueOnce( + makeBacklinksPage([post('did:plc:a', 'post-a'), post('did:plc:b', 'post-b')]), + ) + .mockResolvedValueOnce( + makeBacklinksPage([post('did:plc:b', 'post-b'), post('did:plc:c', 'post-c')]), + ) const utils = new PackageSocialMentionsUtils({ constellation: mockConstellation, @@ -84,8 +65,8 @@ describe('PackageSocialMentionsUtils.getMentions', () => { const result = await utils.getMentions('zod') - expect(result).toEqual({ totalMentions: 4 }) - expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(3) + expect(result).toEqual({ totalMentions: 3, isCapped: false }) + expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(2) expect(mockConstellation.getBackLinks).toHaveBeenCalledWith( 'https://npmx.dev/package/zod', 'app.bsky.feed.post', @@ -106,25 +87,66 @@ describe('PackageSocialMentionsUtils.getMentions', () => { [], 0, ) - expect(mockConstellation.getBackLinks).toHaveBeenCalledWith( - 'https://npmx.dev/package/zod', - 'app.bsky.feed.post', - 'embed.external.uri', - 100, - 'next', - false, - [], - 0, + expect(mockCache.set).toHaveBeenCalledWith( + expect.stringContaining('atproto-social-mentions:zod:total'), + { totalMentions: 3, isCapped: false }, + expect.any(Number), ) + }) + + it('caps at 99 when a source has another page', async () => { + mockConstellation.getBackLinks + .mockResolvedValueOnce(makeBacklinksPage([post('did:plc:a', 'post-a')], 'next')) + .mockResolvedValueOnce(makeBacklinksPage([post('did:plc:b', 'post-b')])) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 99, isCapped: true }) + expect(mockConstellation.getBackLinks).toHaveBeenCalledTimes(2) expect(mockCache.set).toHaveBeenCalledWith( expect.stringContaining('atproto-social-mentions:zod:total'), - { totalMentions: 4 }, + { totalMentions: 99, isCapped: true }, expect.any(Number), ) }) + it('caps at 99 when unique posts exceed 99', async () => { + mockConstellation.getBackLinks.mockResolvedValueOnce( + makeBacklinksPage(Array.from({ length: 100 }, (_, i) => post('did:plc:a', `post-${i}`))), + ) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 99, isCapped: true }) + }) + + it('does not cap exactly 99 unique posts', async () => { + mockConstellation.getBackLinks.mockResolvedValueOnce( + makeBacklinksPage(Array.from({ length: 99 }, (_, i) => post('did:plc:a', `post-${i}`))), + ) + + const utils = new PackageSocialMentionsUtils({ + constellation: mockConstellation, + cache: mockCache, + }) + + const result = await utils.getMentions('zod') + + expect(result).toEqual({ totalMentions: 99, isCapped: false }) + }) + it('returns cached mentions without calling constellation', async () => { - mockCache.get.mockResolvedValueOnce({ totalMentions: 42 }) + mockCache.get.mockResolvedValueOnce({ totalMentions: 42, isCapped: false }) const utils = new PackageSocialMentionsUtils({ constellation: mockConstellation, @@ -134,7 +156,7 @@ describe('PackageSocialMentionsUtils.getMentions', () => { const result = await utils.getMentions('react') expect(mockConstellation.getBackLinks).not.toHaveBeenCalled() - expect(result).toEqual({ totalMentions: 42 }) + expect(result).toEqual({ totalMentions: 42, isCapped: false }) }) it('caches a zero result', async () => { @@ -145,10 +167,10 @@ describe('PackageSocialMentionsUtils.getMentions', () => { const result = await utils.getMentions('unmentioned-pkg') - expect(result).toEqual({ totalMentions: 0 }) + expect(result).toEqual({ totalMentions: 0, isCapped: false }) expect(mockCache.set).toHaveBeenCalledWith( expect.stringContaining('unmentioned-pkg:total'), - { totalMentions: 0 }, + { totalMentions: 0, isCapped: false }, expect.any(Number), ) })