this repo has no description
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}`.