Repository navigation
feat: show number of social mentions on package page #3414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0c88df0
b8ced27
9c4ae58
2820306
78f3feb
3cadc08
662885a
9001b2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| <script lang="ts" setup> | ||
| import type { PackageSocialMentions } from '#shared/types/social' | ||
| import { packageSubjectRef, SOCIAL_MENTIONS_MAX } from '#shared/utils/constants' | ||
|
|
||
| const props = defineProps<{ | ||
| packageName: string | ||
| }>() | ||
|
|
||
| const compactNumberFormatter = useCompactNumberFormatter() | ||
|
|
||
| const { | ||
| data: mentionsData, | ||
| status, | ||
| execute: fetchMentions, | ||
| } = useLazyFetch<PackageSocialMentions>(() => `/api/social/mentions/${props.packageName}`, { | ||
| default: () => ({ totalMentions: 0, isCapped: false }), | ||
| 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) | ||
| const isCapped = computed(() => mentionsData.value?.isCapped ?? false) | ||
| const displayCount = computed(() => | ||
| isCapped.value | ||
| ? `${SOCIAL_MENTIONS_MAX}+` | ||
| : compactNumberFormatter.value.format(totalMentions.value), | ||
| ) | ||
| const isLoading = computed(() => status.value === 'pending' || status.value === 'idle') | ||
| // Search the exact npmx.dev URL to avoid noisy matches on package names like "react". | ||
| const searchUrl = computed( | ||
| () => `https://bsky.app/search?q=${encodeURIComponent(packageSubjectRef(props.packageName))}`, | ||
| ) | ||
| const label = computed(() => | ||
| $t('package.social_mentions.label', { count: displayCount.value }, totalMentions.value), | ||
| ) | ||
| </script> | ||
|
|
||
| <template> | ||
| <LinkBase | ||
| variant="button-secondary" | ||
| classicon="i-lucide:message-circle" | ||
| :to="searchUrl" | ||
| :aria-label="isLoading ? $t('common.loading') : label" | ||
| data-testid="social-mentions" | ||
| class="min-w-36" | ||
| > | ||
| <span v-if="isLoading" class="i-svg-spinners:ring-resize w-3 h-3" aria-hidden="true" /> | ||
| <span v-else>{{ label }}</span> | ||
| </LinkBase> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import * as v from 'valibot' | ||
| import { PackageRouteParamsSchema } from '#shared/schemas/package' | ||
|
|
||
| /** | ||
| * GET /api/social/mentions/:name | ||
| * | ||
| * 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('/') ?? [] | ||
| const { rawPackageName } = parsePackageParams(pkgParamSegments) | ||
|
|
||
| if (!rawPackageName) { | ||
| throw createError({ | ||
| status: 400, | ||
| message: 'package name not provided', | ||
| }) | ||
| } | ||
|
|
||
| let decodedPackageName: string | ||
| try { | ||
| decodedPackageName = decodeURIComponent(rawPackageName) | ||
| } catch { | ||
| throw createError({ | ||
| status: 400, | ||
| message: 'Invalid package name encoding', | ||
| }) | ||
| } | ||
|
|
||
| try { | ||
| const { packageName } = v.parse(PackageRouteParamsSchema, { | ||
| packageName: decodedPackageName, | ||
| }) | ||
|
|
||
| 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', | ||
| }) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| 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` | ||
|
|
||
| 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 | ||
|
|
||
| /** The subset of Constellation that PackageSocialMentionsUtils actually needs. */ | ||
| export type ConstellationMentions = Pick<Constellation, 'getBackLinks'> | ||
|
|
||
| /** | ||
| * Counts unique Bluesky posts that link to an npmx.dev package. | ||
| * | ||
| * 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 | ||
| 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 <T = unknown>( | ||
| url: string, | ||
| options: Parameters<typeof $fetch>[1] = {}, | ||
| _ttl?: number, | ||
| ): Promise<CachedFetchResult<T>> => { | ||
| const data = (await $fetch<T>(url, options)) as T | ||
| return { data, isStale: false, cachedAt: null } | ||
| }, | ||
| ) | ||
| this.cache = deps?.cache ?? getCacheAdapter('generic') | ||
| } | ||
|
|
||
| private async constellationPostKeys(target: string, recordPath: string) { | ||
| const { data } = await this.constellation.getBackLinks( | ||
| target, | ||
| BLUESKY_POST_COLLECTION, | ||
| recordPath, | ||
| 100, | ||
| undefined, | ||
| false, | ||
| [], | ||
| 0, | ||
| ) | ||
|
|
||
| return { | ||
| postKeys: data.records.map(postKey), | ||
| hasMore: Boolean(data.cursor), | ||
| } | ||
|
Comment on lines
+62
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| async getMentions(packageName: string): Promise<PackageSocialMentions> { | ||
| const cacheKey = cachePackageTotalKey(packageName) | ||
| const cached = await this.cache.get<PackageSocialMentions>(cacheKey) | ||
| if (cached) return cached | ||
|
|
||
| const target = packageSubjectRef(packageName) | ||
| const results = await Promise.all( | ||
| BLUESKY_LINK_PATHS.map(recordPath => this.constellationPostKeys(target, recordPath)), | ||
| ) | ||
|
Comment on lines
+73
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| const uniquePosts = new Set<string>() | ||
| 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: isCapped ? SOCIAL_MENTIONS_MAX : uniquePosts.size, | ||
| isCapped, | ||
| } | ||
| await this.cache.set(cacheKey, result, CACHE_MAX_AGE) | ||
| return result | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| function postKey(record: Backlink): string { | ||
| return `${record.did}:${record.collection}:${record.rkey}` | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 35891
Web query:
Nuxt 4.5.2 useAsyncData useFetch error data default catch source GitHubResult:
Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 18323
Script executed:
Repository: npmx-dev/npmx.dev
Length of output: 10212
Distinguish fetch errors from zero mentions.
When the request fails, the button can show
0 mentionsfor a package that has mentions. Use the fetch error state to hide or flag the count. Keep zero visible when the request succeeds with zero mentions.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
meh. let's call this graceful degradation.