From c08a3f693f0cf695ec2462122a7d4e5ca2857344 Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Fri, 17 Apr 2026 04:59:17 +0000 Subject: [PATCH] Add blog posts --- src/lib/blog.ts | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/pages/index.astro | 36 ++++++++++++++---------------------- 2 file(s) changed, 128 insertion(s)(+), 22 deletion(s)(-) diff --git a/src/lib/blog.ts b/src/lib/blog.ts new file mode 100644 --- /dev/null +++ b/src/lib/blog.ts @@ -0,0 +1,114 @@ +/** + * Fetch standard.site documents directly from ATProto via XRPC. + * This bypasses the astro-standard-site loader which has strict schema + * validation that rejects documents with timezone offsets like +00:00. + */ + +export interface BlogPost { + id: string; + uri: string; + title: string; + site: string; + publishedAt: Date; + path?: string; + url?: string; + description?: string; + textContent?: string; + tags: string[]; +} + +const COLLECTION = 'site.standard.document'; + +interface RawDocument { + $type: string; + title?: string; + site?: string; + publishedAt?: string; + updatedAt?: string; + path?: string; + description?: string; + textContent?: string; + tags?: string[]; + content?: unknown; + [key: string]: unknown; +} + +/** + * Fetch blog posts from an ATProto account's PDS. + */ +export async function fetchBlogPosts(config: { + handle: string; + pds: string; + publication?: string; + limit?: number; +}): Promise { + const { handle, pds, publication, limit = 100 } = config; + + // Resolve handle to DID + let did = handle; + if (!did.startsWith('did:')) { + const res = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` + ); + if (!res.ok) throw new Error(`Failed to resolve handle ${handle}: ${res.status}`); + const data = await res.json(); + did = data.did; + } + + // Fetch all documents + const posts: BlogPost[] = []; + let cursor: string | undefined; + + do { + const params = new URLSearchParams({ + repo: did, + collection: COLLECTION, + limit: String(Math.min(limit, 100)), + }); + if (cursor) params.set('cursor', cursor); + + const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`); + if (!res.ok) throw new Error(`listRecords failed: ${res.status}`); + + const data = await res.json(); + + for (const record of data.records as Array<{ uri: string; cid: string; value: RawDocument }>) { + const v = record.value; + + // Skip if no title + if (!v.title) continue; + + // Filter to specific publication if requested + if (publication && v.site !== publication) continue; + + // Parse the date tolerantly (handles +00:00, Z, and other formats) + let publishedAt: Date; + try { + publishedAt = new Date(v.publishedAt ?? ''); + if (isNaN(publishedAt.getTime())) continue; + } catch { + continue; + } + + const rkey = record.uri.split('/').pop() ?? ''; + + posts.push({ + id: rkey, + uri: record.uri, + title: v.title, + site: v.site ?? '', + publishedAt, + path: v.path, + url: v.path ? `https://blog.atmosphere.community${v.path}` : undefined, + description: v.description, + textContent: v.textContent, + tags: v.tags ?? [], + }); + } + + cursor = data.cursor; + if (posts.length >= limit) break; + } while (cursor); + + return posts; +} diff --git a/src/pages/index.astro b/src/pages/index.astro --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -10,8 +10,8 @@ import yaml from 'js-yaml'; import communitiesRaw from '../data/communities.yml?raw'; import appsRaw from '../data/apps.yml?raw'; -import { getCollection } from 'astro:content'; import { getProfile, excerpt } from '../lib/atproto'; +import { fetchBlogPosts } from '../lib/blog'; import { fetchEvents } from '../lib/events'; interface Community { @@ -47,20 +47,16 @@ }> = []; try { - const blogPosts = await getCollection('blog'); - - // Prefer Offprint articles (blog.atmosphere.community publication) + // Fetch only Offprint articles directly via XRPC const OFFPRINT_PUB = 'at://did:plc:lehcqqkwzcwvjvw66uthu5oq/site.standard.publication/3mjnpilwnrp2v'; - const offprintPosts = blogPosts.filter(p => p.data.site === OFFPRINT_PUB && p.data.title); - // Fall back to all posts if Offprint has fewer than 3 articles - const sourcePosts = offprintPosts.length >= 3 ? offprintPosts : blogPosts.filter(p => p.data.title); + const blogPosts = await fetchBlogPosts({ + handle: 'atmosphere.community', + pds: 'https://hydnum.us-west.host.bsky.network', + publication: OFFPRINT_PUB, + }); - const sorted = sourcePosts - .sort((a, b) => { - const dateA = a.data.publishedAt ? new Date(a.data.publishedAt).getTime() : 0; - const dateB = b.data.publishedAt ? new Date(b.data.publishedAt).getTime() : 0; - return dateB - dateA; - }) + const sorted = blogPosts + .sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime()) .slice(0, 6); // Resolve the blog author profile @@ -68,17 +64,13 @@ const authorName = profile.displayName || `@${profile.handle}`; posts = sorted.map(post => { - const postUrl = post.data.url - || (post.data.path ? `https://blog.atmosphere.community${post.data.path}` : 'https://blog.atmosphere.community'); return { - title: post.data.title ?? 'Untitled', - excerpt: excerpt(post.data.textContent ?? post.data.description, 150), + title: post.title, + excerpt: excerpt(post.textContent ?? post.description, 150), author: authorName, - date: post.data.publishedAt - ? new Date(post.data.publishedAt).toISOString().split('T')[0] - : '', - href: postUrl, - tag: post.data.tags?.[0], + date: post.publishedAt.toISOString().split('T')[0], + href: post.url ?? 'https://blog.atmosphere.community', + tag: post.tags?.[0], }; }); } catch (e) { -- tangled.sh