[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt. roe.dev
0

Configure Feed

Select the types of activity you want to include in your feed.

roe.dev / modules / bsky-comments.ts
8.7 kB 279 lines
1import { readFile, writeFile, mkdir } from 'node:fs/promises' 2import { existsSync } from 'node:fs' 3 4import { addServerHandler, addServerTemplate, addTemplate, addTypeTemplate, createResolver, defineNuxtModule, useNuxt } from 'nuxt/kit' 5import { joinURL, withoutTrailingSlash } from 'ufo' 6import { join } from 'pathe' 7import { AtpAgent, AppBskyFeedPost } from '@atproto/api' 8import { toValue } from 'vue' 9 10// when I created my Bluesky account - don't judge me for hard coding it! 11const BLUESKY_ACCOUNT_CREATED = new Date('2023-04-26T05:22:14.855Z') 12 13interface ParsedPost { 14 uri: string 15 createdAt: string 16 links: string[] // URLs found in facets and embeds 17} 18 19interface BlogPost { 20 slug: string 21 path: string 22 url: string 23 date: Date 24 blueskyUri: string | null 25} 26 27export default defineNuxtModule({ 28 meta: { 29 name: 'bsky-comments', 30 }, 31 async setup () { 32 const nuxt = useNuxt() 33 34 addTypeTemplate({ 35 filename: 'types/bsky-runtime-discovery.d.ts', 36 getContents: () => ` 37declare module '#build/bsky-runtime-discovery.mjs' { 38 export const needsRuntimeDiscovery: boolean 39 export const newestPostPath: string | null 40} 41`, 42 }, { nuxt: true }) 43 44 if (nuxt.options._prepare) { 45 return 46 } 47 48 const resolver = createResolver(import.meta.url) 49 const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }) 50 51 const cacheDir = join(nuxt.options.rootDir, 'node_modules', '.cache', 'bluesky-comments') 52 if (!existsSync(cacheDir)) { 53 await mkdir(cacheDir, { recursive: true }) 54 } 55 const blueskyHandle = nuxt.options.runtimeConfig.atproto?.handle || null 56 if (!blueskyHandle) { 57 console.warn('Bluesky handle not configured (no runtimeConfig.atproto.handle). Skipping Bluesky URI discovery.') 58 return 59 } 60 61 // Receive blog data from the markdown module hook and discover Bluesky URIs 62 const blogPosts: BlogPost[] = [] 63 64 nuxt.hook('markdown:blog-entries', async entries => { 65 const siteURL = nuxt.options.site && toValue(nuxt.options.site?.url) 66 if (!siteURL) { 67 return 68 } 69 const feedIterator = createFeedIterator(agent, blueskyHandle) 70 71 for (const entry of entries) { 72 if (!entry.date) continue 73 74 const blogPath = entry.path 75 const blogUrl = withoutTrailingSlash(joinURL(siteURL, blogPath)) 76 const blogDate = new Date(entry.date) 77 const cacheFile = join(cacheDir, `${entry.slug}.json`) 78 79 const blueskyUri = await discoverBlueskyUri() 80 blogPosts.push({ slug: entry.slug, path: blogPath, url: blogUrl, date: blogDate, blueskyUri }) 81 82 // Inject the discovered URI back into the entry 83 if (blueskyUri) { 84 entry.bluesky = blueskyUri 85 } 86 87 async function discoverBlueskyUri (): Promise<string | null> { 88 // Check cache first 89 if (existsSync(cacheFile)) { 90 try { 91 const cacheData = JSON.parse(await readFile(cacheFile, 'utf-8')) as { uri: string | null } 92 return cacheData.uri 93 } 94 catch { 95 // Continue to discover 96 } 97 } 98 99 // 1. explicit bluesky URL in frontmatter 100 if (entry.bluesky?.startsWith('https://bsky.app/')) { 101 const match = entry.bluesky.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/) 102 if (match) { 103 const [, handle, rkey] = match 104 try { 105 const { data: resolved } = await agent.resolveHandle({ handle: handle! }) 106 const uri = `at://${resolved.did}/app.bsky.feed.post/${rkey}` 107 await saveCache(cacheFile, uri) 108 return uri 109 } 110 catch (error) { 111 console.warn(`Failed to resolve Bluesky handle for ${blogPath}:`, error) 112 } 113 } 114 await saveCache(cacheFile, null) 115 return null 116 } 117 118 // 2. blog post date is before Bluesky account creation 119 if (blogDate < BLUESKY_ACCOUNT_CREATED) { 120 await saveCache(cacheFile, null) 121 return null 122 } 123 124 // 3. auto-discover posts linking to this blog URL 125 const searchCutoff = new Date(blogDate.getTime() - 24 * 60 * 60 * 1000) 126 127 while (!feedIterator.isExhausted()) { 128 const oldestFetchedDate = feedIterator.getOldestPostDate() 129 if (oldestFetchedDate && oldestFetchedDate < searchCutoff) { 130 break 131 } 132 await feedIterator.fetchMore() 133 } 134 135 const matchingPosts = feedIterator.posts 136 .filter(post => { 137 const postDate = new Date(post.createdAt) 138 if (postDate < searchCutoff) return false 139 return post.links.some(link => withoutTrailingSlash(link) === blogUrl) 140 }) 141 .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) 142 143 const uri = matchingPosts[0]?.uri ?? null 144 if (uri) { 145 console.log(`Auto-discovered Bluesky post for ${blogPath}`) 146 } 147 await saveCache(cacheFile, uri) 148 return uri 149 } 150 } 151 152 // Sort by date descending to find newest 153 blogPosts.sort((a, b) => b.date.getTime() - a.date.getTime()) 154 const newestPost = blogPosts[0] 155 const needsRuntimeDiscovery = newestPost && !newestPost.blueskyUri 156 157 // Add virtual file for runtime discovery flag (client-side) 158 addTemplate({ 159 filename: 'bsky-runtime-discovery.mjs', 160 getContents: () => `export const needsRuntimeDiscovery = ${!!needsRuntimeDiscovery} 161export const newestPostPath = ${newestPost ? JSON.stringify(newestPost.path) : 'null'}`, 162 write: true, 163 }) 164 165 // Add server template with the blog URL and date 166 addServerTemplate({ 167 filename: 'bsky-runtime-discovery-server.mjs', 168 getContents: () => ` 169export const newestPostUrl = ${needsRuntimeDiscovery ? JSON.stringify(newestPost.url) : 'null'} 170export const newestPostDate = ${needsRuntimeDiscovery ? JSON.stringify(newestPost.date.toISOString()) : 'null'}`, 171 }) 172 173 // Conditionally add the server endpoint only when needed 174 if (needsRuntimeDiscovery) { 175 addServerHandler({ 176 route: '/api/discover-bluesky-post', 177 handler: resolver.resolve('./runtime/server/discover-bluesky-post.get'), 178 }) 179 } 180 }) 181 }, 182}) 183 184async function saveCache (cacheFile: string, uri: string | null) { 185 try { 186 await writeFile(cacheFile, JSON.stringify({ uri })) 187 } 188 catch { 189 // Ignore cache write errors 190 } 191} 192 193function createFeedIterator (agent: AtpAgent, actor: string) { 194 const posts: ParsedPost[] = [] 195 let cursor: string | undefined 196 let exhausted = false 197 let oldestPostDate: Date | null = null 198 199 async function fetchNextPage (): Promise<boolean> { 200 if (exhausted) return false 201 202 try { 203 const { data } = await agent.getAuthorFeed({ 204 actor, 205 limit: 100, 206 cursor, 207 }) 208 209 for (const item of data.feed) { 210 // skip reposts 211 if (item.reason) continue 212 213 const post = item.post 214 if (!AppBskyFeedPost.isRecord(post.record)) continue 215 216 const record = post.record 217 const postDate = new Date(record.createdAt as string) 218 219 if (!oldestPostDate || postDate < oldestPostDate) { 220 oldestPostDate = postDate 221 } 222 223 if (postDate < BLUESKY_ACCOUNT_CREATED) { 224 exhausted = true 225 break 226 } 227 228 const links: string[] = [] 229 230 // Extract links from facets 231 const facets = record.facets as Array<{ features: Array<{ uri?: string, $type?: string }> }> | undefined 232 if (facets) { 233 for (const facet of facets) { 234 for (const feature of facet.features) { 235 if (feature.$type === 'app.bsky.richtext.facet#link' && feature.uri) { 236 links.push(feature.uri) 237 } 238 } 239 } 240 } 241 242 if (post.embed && 'external' in post.embed) { 243 const external = post.embed.external as { uri?: string } 244 if (external.uri) { 245 links.push(external.uri) 246 } 247 } 248 249 if (links.length > 0) { 250 posts.push({ 251 uri: post.uri, 252 createdAt: record.createdAt as string, 253 links, 254 }) 255 } 256 } 257 258 cursor = data.cursor 259 260 if (!cursor) { 261 exhausted = true 262 } 263 264 return true 265 } 266 catch { 267 exhausted = true 268 return false 269 } 270 } 271 272 return { 273 posts, 274 fetchMore: fetchNextPage, 275 getOldestPostDate: () => oldestPostDate, 276 /** Check if we've exhausted all pages */ 277 isExhausted: () => exhausted, 278 } 279}