diff --git a/app/app.vue b/app/app.vue index f6f37048..6dc14cb9 100644 --- a/app/app.vue +++ b/app/app.vue @@ -91,8 +91,6 @@ if (import.meta.server) { { rel: 'mask-icon', color: '#fff', href: '/favicon.svg' }, { rel: 'icon', type: 'image/svg', href: '/favicon.svg' }, { rel: 'alternate', type: 'application/rss+xml', href: '/rss.xml' }, - { rel: 'webmention', href: 'https://webmention.io/roe.dev/webmention' }, - { rel: 'pingback', href: 'https://webmention.io/roe.dev/xmlrpc' }, { rel: 'apple-touch-icon', sizes: '180x180', diff --git a/app/components/BlueskyComment.vue b/app/components/BlueskyComment.vue new file mode 100644 index 00000000..45c10d46 --- /dev/null +++ b/app/components/BlueskyComment.vue @@ -0,0 +1,205 @@ + + + diff --git a/app/components/BlueskyComments.client.vue b/app/components/BlueskyComments.client.vue new file mode 100644 index 00000000..e8c13a9f --- /dev/null +++ b/app/components/BlueskyComments.client.vue @@ -0,0 +1,167 @@ + + + diff --git a/app/components/WebMentions.client.vue b/app/components/WebMentions.client.vue deleted file mode 100644 index deaa4c70..00000000 --- a/app/components/WebMentions.client.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/app/pages/blog/[article].vue b/app/pages/blog/[article].vue index 399f130f..ceeb34f9 100644 --- a/app/pages/blog/[article].vue +++ b/app/pages/blog/[article].vue @@ -48,7 +48,10 @@ :path="path" /> - + @@ -66,7 +69,7 @@ const { data: page } = await useAsyncData( () => ((import.meta.server || import.meta.dev) as true) && queryCollection('blog').path(path.value) - .select('title', 'date', 'tags', 'description') + .select('title', 'date', 'tags', 'description', 'bluesky') .first(), ) diff --git a/budget.json b/budget.json index 45d191af..50f7f56d 100644 --- a/budget.json +++ b/budget.json @@ -8,7 +8,7 @@ }, { "resourceType": "script", - "budget": 86 + "budget": 87 }, { "resourceType": "stylesheet", diff --git a/content.config.ts b/content.config.ts index 0bb71ec5..60724308 100644 --- a/content.config.ts +++ b/content.config.ts @@ -15,6 +15,7 @@ export default defineContentConfig({ tags: z.array(z.string()), description: z.string(), skip_dev: z.boolean().optional(), + bluesky: z.string().optional(), }), }), }, diff --git a/modules/bsky-comments.ts b/modules/bsky-comments.ts new file mode 100644 index 00000000..46264ac3 --- /dev/null +++ b/modules/bsky-comments.ts @@ -0,0 +1,220 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' + +import { defineNuxtModule, useNuxt } from 'nuxt/kit' +import { joinURL, withoutTrailingSlash } from 'ufo' +import { join } from 'pathe' +import { AtpAgent, AppBskyFeedPost } from '@atproto/api' + +// when I created my Bluesky account - don't judge me for hard coding it! +const BLUESKY_ACCOUNT_CREATED = new Date('2023-04-26T05:22:14.855Z') + +interface ParsedPost { + uri: string + createdAt: string + links: string[] // URLs found in facets and embeds +} + +export default defineNuxtModule({ + meta: { + name: 'bsky-comments', + }, + setup () { + const nuxt = useNuxt() + if (nuxt.options._prepare) { + return + } + + const agent = new AtpAgent({ service: 'https://public.api.bsky.app' }) + + const cacheDir = join(nuxt.options.rootDir, 'node_modules', '.cache', 'bluesky-comments') + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }) + } + const blueskyHandle = nuxt.options.social.networks.bluesky!.identifier + + // Shared feed iterator - fetches posts on demand and grows as needed + const feedIterator = createFeedIterator(agent, blueskyHandle) + + nuxt.hook('content:file:afterParse', async ctx => { + const content = ctx.content as { path?: string, date?: string | Date, bluesky?: string } + + if (!content.path?.startsWith('/blog/')) return + + const blogPath = content.path + const blogUrl = withoutTrailingSlash(joinURL(nuxt.options.site.url, blogPath)) + + const safeName = blogPath.replace(/[^a-z0-9]/gi, '-') + const cacheFile = join(cacheDir, `${safeName}.json`) + + if (existsSync(cacheFile)) { + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf-8')) as { uri: string | null } + if (cached.uri) { + content.bluesky = cached.uri + } + return + } + catch { + // Continue to fetch + } + } + + let discoveredUri: string | null = null + + // 1. explicit bluesky URL in frontmatter - simply resolve + if (content.bluesky?.startsWith('https://bsky.app/')) { + const match = content.bluesky.match(/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/) + if (match) { + const [, handle, rkey] = match + try { + const { data } = await agent.resolveHandle({ handle: handle! }) + content.bluesky = `at://${data.did}/app.bsky.feed.post/${rkey}` + discoveredUri = content.bluesky + } + catch (error) { + console.warn(`Failed to resolve Bluesky handle for ${blogPath}:`, error) + } + } + saveCache(cacheDir, cacheFile, discoveredUri) + } + + // 2. auto-discover posts linking to this blog URL + const blogDate = content.date ? new Date(content.date) : null + + if (blogDate && blogDate < BLUESKY_ACCOUNT_CREATED) { + saveCache(cacheDir, cacheFile, null) + return + } + + const searchCutoff = blogDate + ? new Date(blogDate.getTime() - 24 * 60 * 60 * 1000) + : BLUESKY_ACCOUNT_CREATED + + while (!feedIterator.isExhausted()) { + const oldestFetchedDate = feedIterator.getOldestPostDate() + if (oldestFetchedDate && oldestFetchedDate < searchCutoff) { + break + } + await feedIterator.fetchMore() + } + + const matchingPosts: ParsedPost[] = [] + for (const post of feedIterator.posts) { + const postDate = new Date(post.createdAt) + const hasMatchingLink = post.links.some(link => withoutTrailingSlash(link) === blogUrl) + if (postDate >= searchCutoff && hasMatchingLink) { + matchingPosts.push(post) + } + } + + if (matchingPosts.length > 0) { + // Sort by `createdAt` ascending to get the first announcement) + matchingPosts.sort((a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ) + content.bluesky = matchingPosts[0]!.uri + discoveredUri = content.bluesky + console.log(`Auto-discovered Bluesky post for ${blogPath}`) + } + + saveCache(cacheDir, cacheFile, discoveredUri) + }) + }, +}) + +function saveCache (cacheDir: string, cacheFile: string, uri: string | null) { + try { + writeFileSync(cacheFile, JSON.stringify({ uri })) + } + catch { + // Ignore cache write errors + } +} + +function createFeedIterator (agent: AtpAgent, actor: string) { + const posts: ParsedPost[] = [] + let cursor: string | undefined + let exhausted = false + let oldestPostDate: Date | null = null + + async function fetchNextPage (): Promise { + if (exhausted) return false + + try { + const { data } = await agent.getAuthorFeed({ + actor, + limit: 100, + cursor, + }) + + for (const item of data.feed) { + // skip reposts + if (item.reason) continue + + const post = item.post + if (!AppBskyFeedPost.isRecord(post.record)) continue + + const record = post.record + const postDate = new Date(record.createdAt as string) + + if (!oldestPostDate || postDate < oldestPostDate) { + oldestPostDate = postDate + } + + if (postDate < BLUESKY_ACCOUNT_CREATED) { + exhausted = true + break + } + + const links: string[] = [] + + // Extract links from facets + const facets = record.facets as Array<{ features: Array<{ uri?: string, $type?: string }> }> | undefined + if (facets) { + for (const facet of facets) { + for (const feature of facet.features) { + if (feature.$type === 'app.bsky.richtext.facet#link' && feature.uri) { + links.push(feature.uri) + } + } + } + } + + if (post.embed && 'external' in post.embed) { + const external = post.embed.external as { uri?: string } + if (external.uri) { + links.push(external.uri) + } + } + + if (links.length > 0) { + posts.push({ + uri: post.uri, + createdAt: record.createdAt as string, + links, + }) + } + } + + cursor = data.cursor + + if (!cursor) { + exhausted = true + } + + return true + } + catch { + exhausted = true + return false + } + } + + return { + posts, + fetchMore: fetchNextPage, + getOldestPostDate: () => oldestPostDate, + /** Check if we've exhausted all pages */ + isExhausted: () => exhausted, + } +} diff --git a/nuxt.config.ts b/nuxt.config.ts index 141f2549..0c914bab 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -353,7 +353,7 @@ export default defineNuxtConfig({ contentSecurityPolicy: { 'script-src-attr': ['\'self\'', '\'unsafe-inline\''], 'script-src': ['\'self\'', '\'unsafe-inline\'', 'https://static.cloudflareinsights.com'], - 'img-src': ['\'self\'', 'data:', 'https://avatars.githubusercontent.com', 'https://www.google.com', 'https://cdn.sanity.io', 'https://*.gstatic.com'], + 'img-src': ['\'self\'', 'data:', 'https://avatars.githubusercontent.com', 'https://www.google.com', 'https://cdn.sanity.io', 'https://*.gstatic.com', 'https://cdn.bsky.app'], }, }, }, diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index 8c1fbc11..feb14bc6 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -36,7 +36,7 @@ describe('project sizes', () => { stats.client = await analyzeSizes('**/*.js', publicDir) expect .soft(roundToKilobytes(stats.client.totalBytes)) - .toMatchInlineSnapshot(`"507k"`) + .toMatchInlineSnapshot(`"512k"`) expect.soft(stats.client.files.map(f => f.replace(/\..*\.js/, '.js').replace(/_scripts\/.*\.js/, '_scripts/script.js')).sort()) .toMatchInlineSnapshot(` [ @@ -79,7 +79,7 @@ describe('project sizes', () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes)) - .toMatchInlineSnapshot(`"912k"`) + .toMatchInlineSnapshot(`"913k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect