[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
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}