Something went wrong. Try again.
Website hub for the atmosphere community, aggregating posts, events, regional, and more
Something went wrong. Try again.
9.8 kB · 281 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282import { defineLiveCollection } from "astro:content";import { z } from "astro/zod";import { defineAtProtoLiveCollection } from "@fujocoded/astro-atproto-loader";import { getProfile, prefetchSourceProfiles } from "./lib/community/index.js";import { hasOpenSocialProfile } from "./lib/community/opensocial-profile.js";import yaml from "js-yaml";import communitiesRaw from "./data/communities.yml?raw";import { eventFilters, eventGroupKey, eventsOutputSchema, feedFilters, feedOutputSchema, feedTransformers, parseSharedEventRecord, transformEventGroup, type LoaderArgs,} from "./lib/live-handlers";import { createDiscourseActivityLoader, discourseActivityTopicSchema,} from "./lib/discourse-activity";import { createAvatarFallback, hueFromString,} from "./lib/avatar-fallback";
interface CommunityDefinition { name: string; handle: string; location: string; region?: string; description?: string; bluesky?: string; website?: string;}
type AvatarSource = | { url: string } | { initials: string; color: string };
interface CommunityCardData extends CommunityDefinition, Record<string, unknown> { avatar: AvatarSource; // Derived at load time: true when the account's repo carries a // community.opensocial.profile record (i.e. runs the opensocial.community software). isOpenSocialCommunity: boolean;}
function communityAvatar(handle: string, url?: string): AvatarSource { if (url) return { url }; return { initials: avatarInitials(handle), color: avatarColor(handle) };}
// Strip well-known atproto/bluesky suffixes so the abbreviation reflects the// distinctive part of a handle (e.g. "@nyc.atproto.camp" → "NYC").const HANDLE_SUFFIXES = /\.(bsky\.social|atproto\.camp|atprotocol\.community|atprotocol\.space)$/;function avatarInitials(handle: string): string { return handle .replace(HANDLE_SUFFIXES, "") .replace(/^atproto\.?/, "") .slice(0, 3) .toUpperCase();}function avatarColor(handle: string): string { return `oklch(50% 0.13 ${hueFromString(handle)})`;}
function enrichAuthor(author: { did: string; handle: string; displayName?: string; avatar?: string;}) { const label = author.displayName || `@${author.handle}`; return { did: author.did, handle: author.handle, displayName: author.displayName, avatar: author.avatar ? { url: author.avatar } : createAvatarFallback({ label }), };}
function enrichOptionalAuthor( author: | { did: string; handle: string; displayName?: string; avatar?: string; } | undefined,) { return author ? enrichAuthor(author) : undefined;}
const communityDefinitions = yaml.load(communitiesRaw) as CommunityDefinition[];// atmosphere.community is the site's own account — kept separate from the YAML-defined// communities because it publishes a different lexicon (site.standard.document, see feed below)// and isn't itself a "community" in the listing sense.const communityAccounts = [ "atmosphere.community", ...communityDefinitions.map((community) => community.handle),];const ACTIVITY_CACHE_TTL_MS = 1000 * 60 * 5;const LIMIT_RECORDS_PER_SOURCE = 200;const MAX_PAGES_PER_SOURCE = 2;
const forum = defineLiveCollection({ loader: createDiscourseActivityLoader({ baseUrl: "https://discourse.atprotocol.community", source: { type: "latest" }, limit: 10, cacheTtlMs: ACTIVITY_CACHE_TTL_MS, }), schema: discourseActivityTopicSchema,});
// Warm the source-profile cache once at server boot. Per-record transformers// in lib/live-handlers (e.g. site.standard.document → getProfile('atmosphere.community'),// shared-content → getProfile(author handle)) become cache hits for our curated set.// External authors referenced from shared-content fall through to the regular// profile cache. The source cache is never evicted; the general one may be later.await prefetchSourceProfiles(communityAccounts);
const feed = defineAtProtoLiveCollection({ outputSchema: feedOutputSchema, cacheTtl: ACTIVITY_CACHE_TTL_MS, sources: [ // The site's own curated posts use site.standard.document; community accounts share // links via community.opensocial.sharedContent. Both feed into the same output schema // with per-collection filter/transform dispatch below. { repo: "atmosphere.community", collection: "site.standard.document" as const, limit: LIMIT_RECORDS_PER_SOURCE, maxPages: MAX_PAGES_PER_SOURCE, }, ...communityAccounts.map((repo) => ({ repo, collection: "community.opensocial.sharedContent" as const, limit: LIMIT_RECORDS_PER_SOURCE, maxPages: MAX_PAGES_PER_SOURCE, })), ], // A single broken/unreachable repo shouldn't blank out the whole feed during build. onSourceError: "skip", filter: (ctx) => feedFilters[ctx.collection](ctx as LoaderArgs), // Enrich the author with avatar-fallback fields (initials + color) here in the // live config so components consume ready-to-render data instead of recomputing. transform: async (ctx) => { const result = await feedTransformers[ctx.collection](ctx as LoaderArgs); if (!result) return null; return { ...result, data: { ...result.data, author: enrichAuthor(result.data.author), sharedBy: enrichOptionalAuthor(result.data.sharedBy), }, }; },});
const events = defineAtProtoLiveCollection({ outputSchema: eventsOutputSchema, cacheTtl: ACTIVITY_CACHE_TTL_MS, sources: [ // Two paths into the events list: // 1. Native calendar records authored by the community itself. // 2. Shared-content records pointing at someone else's event — re-resolved by the // grouped transformer so the listing surfaces events a community is *boosting*. // Natives are listed first so that, for any group that contains both a native and // its reshares, the native arrives at index 0 of `records` and wins as canonical. ...communityAccounts.map((repo) => ({ repo, collection: "community.lexicon.calendar.event" as const, limit: LIMIT_RECORDS_PER_SOURCE, maxPages: MAX_PAGES_PER_SOURCE, })), ...communityAccounts.map((repo) => ({ repo, collection: "community.opensocial.sharedContent" as const, limit: LIMIT_RECORDS_PER_SOURCE, maxPages: MAX_PAGES_PER_SOURCE, parseRecord: (value: unknown) => parseSharedEventRecord(value, repo), })), ], onSourceError: "skip", filter: (ctx) => eventFilters[ctx.collection](ctx as LoaderArgs), // groupBy collapses native + reshares of the same canonical at-URI into one entry; // the grouped transform picks the canonical record and annotates `original`/`sharedBy`. groupBy: (ctx) => eventGroupKey(ctx as LoaderArgs), transform: (args) => transformEventGroup({ key: args.key, records: args.records as LoaderArgs[], fetchRecord: args.fetchRecord, }),});
// The community list itself is YAML-driven (curated, not network-discovered). The loader's// only job is to enrich each static entry with the live ATProto profile avatar.const communities = defineLiveCollection({ loader: { name: "communities-live", async loadCollection() { // allSettled, not all: a single unreachable PDS shouldn't blank out the whole listing. // Failed profile lookups simply fall back to no avatar; a failed opensocial // probe (hasOpenSocialProfile swallows its own errors) falls back to false. const [profileResults, openSocialResults] = await Promise.all([ Promise.allSettled( communityDefinitions.map((community) => getProfile(community.handle)), ), Promise.allSettled( communityDefinitions.map((community) => hasOpenSocialProfile(community.handle), ), ), ]);
return { entries: communityDefinitions.map((community, index) => { const result = profileResults[index]; const profileAvatar = result?.status === "fulfilled" ? result.value.avatar : undefined; const openSocial = openSocialResults[index]; return { id: community.handle, data: { ...community, avatar: communityAvatar(community.handle, profileAvatar), isOpenSocialCommunity: openSocial?.status === "fulfilled" ? openSocial.value : false, } satisfies CommunityCardData, }; }), }; }, // Single-entry path used by per-community detail pages — avoids fetching every profile // when only one is needed. async loadEntry({ filter }: { filter: { id: string } }) { const community = communityDefinitions.find( (entry) => entry.handle === filter.id, ); if (!community) return undefined;
const [profile, isOpenSocialCommunity] = await Promise.all([ getProfile(community.handle).catch(() => undefined), hasOpenSocialProfile(community.handle), ]); return { id: community.handle, data: { ...community, avatar: communityAvatar(community.handle, profile?.avatar), isOpenSocialCommunity, } satisfies CommunityCardData, }; }, }, schema: z.object({ name: z.string(), handle: z.string(), location: z.string(), region: z.string().optional(), description: z.string().optional(), bluesky: z.string().optional(), website: z.string().optional(), isOpenSocialCommunity: z.boolean(), avatar: z.union([ z.object({ url: z.string() }), z.object({ initials: z.string(), color: z.string() }), ]), }),});
export const collections = { feed, events, communities, forum };