From d4a94667ec23caec47ea6b8cddf792649c76eed7 Mon Sep 17 00:00:00 2001 From: Jonathan Yeong Date: Thu, 5 Feb 2026 16:54:35 -0500 Subject: [PATCH] feat: add blog post composables These composables will be used in this PR https://github.com/npmx-dev/npmx.dev/pull/257. Co-authored-by: Brandon Hurrington --- app/composables/useAuthorProfiles.ts | 30 ++++++++ app/composables/useBlogPostBlueskyLink.ts | 92 +++++++++++++++++++++++ app/composables/useBlueskyComments.ts | 52 +++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 app/composables/useAuthorProfiles.ts create mode 100644 app/composables/useBlogPostBlueskyLink.ts create mode 100644 app/composables/useBlueskyComments.ts diff --git a/app/composables/useAuthorProfiles.ts b/app/composables/useAuthorProfiles.ts new file mode 100644 index 000000000..134a15539 --- /dev/null +++ b/app/composables/useAuthorProfiles.ts @@ -0,0 +1,30 @@ +import type { Author, ResolvedAuthor } from '#shared/schemas/blog' + +/** + * @public + */ +export function useAuthorProfiles(authors: Author[]) { + const authorsJson = JSON.stringify(authors) + + const { data } = useFetch('/api/atproto/author-profiles', { + query: { + authors: authorsJson, + }, + }) + + const resolvedAuthors = computed( + () => + data.value?.authors ?? + authors.map(author => ({ + ...author, + avatar: null, + profileUrl: author.blueskyHandle + ? `https://bsky.app/profile/${author.blueskyHandle}` + : null, + })), + ) + + return { + resolvedAuthors, + } +} diff --git a/app/composables/useBlogPostBlueskyLink.ts b/app/composables/useBlogPostBlueskyLink.ts new file mode 100644 index 000000000..4be778da7 --- /dev/null +++ b/app/composables/useBlogPostBlueskyLink.ts @@ -0,0 +1,92 @@ +import { Constellation } from '#shared/utils/constellation' +import { NPMX_SITE } from '#shared/utils/constants' + +const BLOG_BACKLINK_TTL_IN_SECONDS = 60 * 5 + +// TODO: Remove did when going live +const TESTING_ROE_DID = 'did:plc:jbeaa5kdaladzwq3r7f5xgwe' +// const TESTING_BACKLINK_URL = 'https://roe.dev/blog/the-golden-thread' +// const NPMX_DID = 'did:plc:u5zp7npt5kpueado77kuihyz' + +/** + * @public + */ +export interface BlogPostBlueskyLink { + did: string + rkey: string + postUri: string +} + +/** + * @public + */ +export function useBlogPostBlueskyLink(slug: MaybeRefOrGetter) { + const cachedFetch = useCachedFetch() + + const blogUrl = computed(() => { + const s = toValue(slug) + if (!s) return null + return `${NPMX_SITE}/blog/${s}` + // return TESTING_BACKLINK_URL + }) + + return useAsyncData( + () => (blogUrl.value ? `blog-bsky-link:${blogUrl.value}` : 'blog-bsky-link:none'), + async () => { + const url = blogUrl.value + if (!url) return null + + const constellation = new Constellation(cachedFetch) + + try { + // Try embed.external.uri first (link card embeds) + const { data: embedBacklinks } = await constellation.getBackLinks( + url, + 'app.bsky.feed.post', + 'embed.external.uri', + 1, + undefined, + true, + [[TESTING_ROE_DID]], + BLOG_BACKLINK_TTL_IN_SECONDS, + ) + + const embedRecord = embedBacklinks.records[0] + if (embedRecord) { + return { + did: embedRecord.did, + rkey: embedRecord.rkey, + postUri: `at://${embedRecord.did}/app.bsky.feed.post/${embedRecord.rkey}`, + } + } + + // Try facets.features.uri (URLs in post text) + const { data: facetBacklinks } = await constellation.getBackLinks( + url, + 'app.bsky.feed.post', + 'facets[].features[app.bsky.richtext.facet#link].uri', + 1, + undefined, + true, + [[TESTING_ROE_DID]], + BLOG_BACKLINK_TTL_IN_SECONDS, + ) + + const facetRecord = facetBacklinks.records[0] + if (facetRecord) { + return { + did: facetRecord.did, + rkey: facetRecord.rkey, + postUri: `at://${facetRecord.did}/app.bsky.feed.post/${facetRecord.rkey}`, + } + } + } catch (error: unknown) { + // Constellation unavailable or error - fail silently + // But during dev we will get an error + if (import.meta.dev) console.error('[Bluesky] Constellation error:', error) + } + + return null + }, + ) +} diff --git a/app/composables/useBlueskyComments.ts b/app/composables/useBlueskyComments.ts new file mode 100644 index 000000000..fa2f02caa --- /dev/null +++ b/app/composables/useBlueskyComments.ts @@ -0,0 +1,52 @@ +import type { Comment } from '#shared/types/blog-post' +import { BLUESKY_COMMENTS_REQUEST } from '#shared/utils/constants' + +/** + * @public + */ +export type BlueskyCommentsState = { + thread: Comment | null + likes: Array<{ + actor: { + did: string + handle: string + displayName?: string + avatar?: string + } + }> + totalLikes: number + postUrl: string | null + _empty?: boolean + _error?: boolean +} + +// Handles both server-side caching and client-side hydration +/** + * @public + */ +export function useBlueskyComments(postUri: MaybeRefOrGetter) { + const uri = toRef(postUri) + + const { data, pending, error, refresh } = useFetch(BLUESKY_COMMENTS_REQUEST, { + query: { uri }, + key: () => `bsky-comments-${uri.value}`, + default: (): BlueskyCommentsState => ({ + thread: null, + likes: [], + totalLikes: 0, + postUrl: null, + }), + }) + + // Hydrate with fresh data on client side + onMounted(() => { + refresh() + }) + + return { + data, + pending, + error, + refresh, + } +}