this repo has no description
0
fork

Configure Feed

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

at main 326 lines 13 kB view raw view rendered
1# RFC: ATProto Personal Site — Astro Architecture 2 3## Summary 4 5Rebuild the Tangled-hosted personal site as an Astro 6 static site that pulls all meaningful AT Protocol activity from the user's PDS. Build-time fetching for stable content, client-side hydration for live feeds. 6 7## Motivation 8 9The current single `index.html` approach doesn't scale. The site needs to render 6+ distinct AT Protocol collection types, each with different schemas and display requirements. Astro gives us component architecture, build-time data fetching, TypeScript, and zero-JS-by-default output — all while producing plain static files Tangled can serve. 10 11## Architecture Overview 12 13``` 14src/ 15├── layouts/ 16│ └── BaseLayout.astro # Shell: fonts, meta, Impressionist CSS vars, body structure 17├── components/ 18│ ├── ProfileHeader.astro # Avatar, banner, bio, stats (build-time) 19│ ├── TabNav.astro # Tab navigation (static HTML, client JS for switching) 20│ ├── posts/ 21│ │ ├── PostFeed.astro # Client-side island — fetches live from public API 22│ │ ├── PostCard.astro # Single post with embeds, engagement 23│ │ ├── ThreadToggle.astro # Expandable self-thread (client-side fetch) 24│ │ └── EmbedRenderer.astro # Images, external links, quotes, video thumbs 25│ ├── writing/ 26│ │ ├── WritingList.astro # Build-time list of blog post cards 27│ │ └── ArticleRenderer.astro # Renders blog.pckt.block.* content blocks 28│ ├── music/ 29│ │ ├── TrackList.astro # Build-time list of Plyr tracks 30│ │ └── TrackCard.astro # Artwork, title, artist, duration, play button (island) 31│ ├── repos/ 32│ │ └── RepoList.astro # Build-time Tangled repo cards 33│ ├── annotations/ 34│ │ └── AnnotationList.astro # Build-time Margin annotation cards 35│ ├── notes/ 36│ │ └── NoteList.astro # Build-time Aether OS textpad cards 37│ ├── follows/ 38│ │ └── FollowGrid.astro # Client-side island — paginated follows 39│ └── feeds/ 40│ └── FeedList.astro # Build-time custom feed cards 41├── lib/ 42│ ├── atproto.ts # PDS client: resolve DID, listRecords, typed helpers 43│ ├── bluesky.ts # Public API client: getProfile, getAuthorFeed, etc. 44│ ├── types.ts # TypeScript types for all AT Proto record schemas 45│ └── render.ts # Shared: facet rendering, timeAgo, escapeHtml 46├── styles/ 47│ └── global.css # Impressionist theme: CSS vars, base typography, layout 48└── pages/ 49 ├── index.astro # Main page: profile + tabbed sections 50 └── writing/ 51 └── [...slug].astro # Dynamic blog post pages (build-time generated) 52``` 53 54## Data Flow 55 56### Build-Time (Astro SSG) 57 58Fetched once at `astro build`, baked into static HTML: 59 60| Collection | Source | Endpoint | 61|---|---|---| 62| Profile | Public API | `app.bsky.actor.getProfile` | 63| Blog posts | PDS | `com.atproto.repo.listRecords``site.standard.document` | 64| Publications | PDS | `com.atproto.repo.listRecords``site.standard.publication` | 65| Music tracks | PDS | `com.atproto.repo.listRecords``fm.plyr.track` | 66| Tangled repos | PDS | `com.atproto.repo.listRecords``sh.tangled.repo` | 67| Annotations | PDS | `com.atproto.repo.listRecords``at.margin.annotation` | 68| Notes | PDS | `com.atproto.repo.listRecords``computer.aetheros.textpad.text` | 69| Custom feeds | Public API | `app.bsky.feed.getActorFeeds` | 70 71PDS resolution: resolve DID from handle via `com.atproto.identity.resolveHandle`, then look up PDS endpoint from `plc.directory/{did}`. 72 73### Client-Side (Astro Islands) 74 75Hydrated at runtime for live/paginated data: 76 77| Component | Why client-side | 78|---|---| 79| `PostFeed` | Posts change frequently, user expects latest | 80| `ThreadToggle` | Lazy-loaded on click via `getPostThread` | 81| `FollowGrid` | 5800+ follows, needs pagination | 82| `TrackCard` | HTML5 `<audio>` playback controls need JS | 83 84These use Astro's `client:visible` or `client:idle` directives. No framework needed — vanilla JS islands via `<script>` tags in Astro components. 85 86## PDS Client (`lib/atproto.ts`) 87 88```typescript 89const DID = 'did:plc:c7frv4rcitff3p2nh7of5bcv'; 90const HANDLE = 'natespilman.com'; 91 92interface ListRecordsResponse<T> { 93 records: Array<{ uri: string; cid: string; value: T }>; 94 cursor?: string; 95} 96 97async function resolvePDS(did: string): Promise<string> { 98 const res = await fetch(`https://plc.directory/${did}`); 99 const doc = await res.json(); 100 const service = doc.service.find(s => s.type === 'AtprotoPersonalDataServer'); 101 return service.serviceEndpoint; 102} 103 104async function listAllRecords<T>( 105 pds: string, 106 collection: string 107): Promise<T[]> { 108 const records: T[] = []; 109 let cursor: string | undefined; 110 do { 111 const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`); 112 url.searchParams.set('repo', DID); 113 url.searchParams.set('collection', collection); 114 url.searchParams.set('limit', '100'); 115 if (cursor) url.searchParams.set('cursor', cursor); 116 const res = await fetch(url); 117 const data: ListRecordsResponse<T> = await res.json(); 118 records.push(...data.records.map(r => r.value)); 119 cursor = data.cursor; 120 } while (cursor); 121 return records; 122} 123``` 124 125Build-time calls use this to fetch all records. Client-side islands use the public API directly (no PDS access needed for posts/follows). 126 127## Record Types (`lib/types.ts`) 128 129Key schemas based on actual PDS data: 130 131```typescript 132interface PlyrTrack { 133 $type: 'fm.plyr.track'; 134 title: string; 135 artist: string; 136 audioUrl: string; 137 imageUrl?: string; 138 duration: number; // seconds 139 createdAt: string; 140} 141 142interface LeafletDocument { 143 $type: 'pub.leaflet.document'; 144 title: string; 145 author: string; 146 publishedAt: string; 147 description?: string; 148 tags?: string[]; 149 pages: LeafletPage[]; 150 publication?: string; 151} 152 153interface LeafletPage { 154 id: string; 155 $type: 'pub.leaflet.pages.linearDocument'; 156 blocks: LeafletBlockWrapper[]; 157} 158 159interface LeafletBlockWrapper { 160 $type: 'pub.leaflet.pages.linearDocument#block'; 161 block: LeafletBlock; 162} 163 164type LeafletBlock = 165 | { $type: 'pub.leaflet.blocks.text'; plaintext: string; facets?: Facet[] } 166 | { $type: 'pub.leaflet.blocks.header'; plaintext: string; level: number; facets?: Facet[] } 167 | { $type: 'pub.leaflet.blocks.code'; plaintext: string; language?: string } 168 | { $type: 'pub.leaflet.blocks.unorderedList'; children: ListItem[] }; 169 170interface TangledRepo { 171 $type: 'sh.tangled.repo'; 172 name: string; 173 description?: string; 174 knot: string; 175 createdAt: string; 176} 177 178interface MarginAnnotation { 179 $type: 'at.margin.annotation'; 180 body: { value: string; format: string }; 181 target: { 182 title?: string; 183 source: string; 184 selector?: { type: string; exact: string }; 185 }; 186 createdAt: string; 187 motivation: string; 188} 189 190interface AetherosTextpad { 191 $type: 'computer.aetheros.textpad.text'; 192 title: string; 193 body: string; 194 createdAt: string; 195 updatedAt: string; 196} 197``` 198 199## Blog Rendering Strategy 200 201Single canonical source: `site.standard.document` (18 docs). Skip `pub.leaflet.document` (duplicate subset). 202 203### Content format (`blog.pckt.block.*`) 204- `blog.pckt.block.text` — paragraphs with optional facets (links, bold, etc.) 205- `blog.pckt.block.heading` — section headings with level 206- `blog.pckt.block.code` — code blocks with language 207- `blog.pckt.block.image` — embedded images (blob refs) 208- `blog.pckt.block.list` — ordered/unordered lists 209 210### External linking 211Each doc has `site` (AT URI → `site.standard.publication`) and `path`. At build time: 2121. Fetch all publications, build a map of AT URI → `url` 2132. For each doc, construct external URL: `{publication.url}{doc.path}` 2143. Render a "Read on {platform}" link on each article card 215 216### Display 217- **Writing tab**: article cards sorted by `publishedAt` — title, date, tags, description preview 218- **Individual pages**: `/writing/{slug}` — full rendered article with "Read on pckt.blog" / "Read on leaflet.pub" link 219- Code blocks get `<pre><code>` with monospace styling 220 221## Tab Structure 222 223``` 224Posts | Writing | Music | Repos | Annotations | Notes | Following | Feeds 225``` 226 227- **Posts**: Client-side island. Live Bluesky feed, thread expansion, pagination 228- **Writing**: Build-time. Leaflet blog posts rendered as expandable article cards 229- **Music**: Build-time list + client island for audio playback. Artwork, title, artist, `<audio>` element 230- **Repos**: Build-time. Cards linking to `tangled.org/natespilman.com/{repo}` 231- **Annotations**: Build-time. Quote + comment cards linking to source URLs 232- **Notes**: Build-time. Title + body, sorted by updatedAt 233- **Following**: Client-side island. Paginated grid (too many to build-time render) 234- **Feeds**: Build-time. Custom feed generator cards 235 236## Audio Player Design 237 238The `TrackCard` component uses a `client:visible` island: 239 240```html 241<div class="track-card"> 242 <img src={track.imageUrl} alt="" class="track-artwork" /> 243 <div class="track-info"> 244 <div class="track-title">{track.title}</div> 245 <div class="track-artist">{track.artist}</div> 246 <div class="track-duration">{formatDuration(track.duration)}</div> 247 </div> 248 <button class="track-play" data-src={track.audioUrl}>Play</button> 249</div> 250``` 251 252A single shared `<audio>` element at the page level. Play buttons swap the `src` and toggle play/pause. No framework needed. 253 254## Styling 255 256Keep the Impressionist theme from the current site: 257- Playfair Display + Inter font pairing 258- CSS custom properties for the warm palette (lavender, soft-blue, peach, sage, cream) 259- Radial gradient background washes 260- Soft borders, rounded cards, subtle shadows 261- `global.css` defines all vars and base styles 262- Components use scoped `<style>` tags in Astro 263 264## Build & Deploy 265 266```json 267// package.json 268{ 269 "scripts": { 270 "dev": "astro dev", 271 "build": "astro build", 272 "preview": "astro preview" 273 } 274} 275``` 276 277### Tangled config 278- **Deploy directory**: `/dist` 279- **Branch**: `main` 280 281### Workflow 282```bash 283npm run build # Fetches PDS data, generates dist/ 284git add -A 285git commit -m "rebuild" 286git push origin main # Tangled auto-deploys from dist/ 287``` 288 289### Staleness & Automated Rebuilds 290Build-time content (blog posts, music, repos) only updates on rebuild. Posts and follows are client-side and always live. 291 292A Tangled Spindle runs on every push to `main`: 293```yaml 294# .tangled/spindle.yaml (or equivalent) 295steps: 296 - name: build 297 run: npm ci && npm run build 298``` 299This ensures `dist/` is always freshly built from the latest source + PDS data on deploy. 300 301## Decisions 302 303| Decision | Choice | Rationale | 304|---|---|---| 305| Framework for islands | Vanilla JS (`<script>` in Astro) | No React/Svelte needed. Posts feed + audio player are simple enough. Keeps bundle at zero KB framework JS. | 306| Blog post routing | `/writing/{slug}` pages | Each post gets its own page with full rendered content + "Read on {platform}" external link. | 307| Skip `blog.pckt.document` + `pub.leaflet.document` | Yes | Both are subsets/references of `site.standard.document`. Use Standard as the single canonical source (18 docs). | 308| Skip `blue.flashes.feed.post` | Yes | Records contain only timestamps, no text. | 309| Skip pixel art rendering | Yes | Raw encoded layer data. Would need a canvas renderer. Low value. | 310| PDS endpoint | Resolve dynamically at build | Future-proof if PDS migrates. Cache in build. | 311| Syntax highlighting | CSS-only or none | Avoid shipping highlight.js. Use `<code>` with monospace styling. Can add later. | 312 313## Resolved Questions 314 3151. **Individual blog post pages?** Yes — generate `/writing/{slug}` pages. Each page links out to the external source URL (`{publication.url}{doc.path}`), e.g. `https://pioneer.pckt.blog/the-bluesky-ecosystem-...` or `https://nate-learns-dsa.leaflet.pub/grind-75-...`. 3162. **Rebuild automation?** Yes — configure a Tangled Spindle to rebuild on every push to `main`. The Spindle runs `npm run build` and the deploy directory picks up `dist/`. 3173. **Standard vs Leaflet docs?** `site.standard.document` is the canonical collection (18 docs). `pub.leaflet.document` is a subset (15 of the same Grind75 posts in a different block format). **Use only `site.standard.document`** — skip Leaflet to avoid duplicates. 318 319### Publications (external blog URLs) 320 321| Publication | URL | Content | 322|---|---|---| 323| Nate Spilman (pckt) | `https://pioneer.pckt.blog` | Main blog — "ATProto for normies", etc. | 324| Nate learns DSA (leaflet) | `https://nate-learns-dsa.leaflet.pub` | Grind75 leetcode series | 325 326Each `site.standard.document` has a `site` field (AT URI → publication) and a `path` field. At build time, resolve publication URIs to their `url` values, then construct external links as `{publication.url}{path}`.