RFC: ATProto Personal Site — Astro Architecture#
Summary#
Rebuild 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.
Motivation#
The 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.
Architecture Overview#
src/
├── layouts/
│ └── BaseLayout.astro # Shell: fonts, meta, Impressionist CSS vars, body structure
├── components/
│ ├── ProfileHeader.astro # Avatar, banner, bio, stats (build-time)
│ ├── TabNav.astro # Tab navigation (static HTML, client JS for switching)
│ ├── posts/
│ │ ├── PostFeed.astro # Client-side island — fetches live from public API
│ │ ├── PostCard.astro # Single post with embeds, engagement
│ │ ├── ThreadToggle.astro # Expandable self-thread (client-side fetch)
│ │ └── EmbedRenderer.astro # Images, external links, quotes, video thumbs
│ ├── writing/
│ │ ├── WritingList.astro # Build-time list of blog post cards
│ │ └── ArticleRenderer.astro # Renders blog.pckt.block.* content blocks
│ ├── music/
│ │ ├── TrackList.astro # Build-time list of Plyr tracks
│ │ └── TrackCard.astro # Artwork, title, artist, duration, play button (island)
│ ├── repos/
│ │ └── RepoList.astro # Build-time Tangled repo cards
│ ├── annotations/
│ │ └── AnnotationList.astro # Build-time Margin annotation cards
│ ├── notes/
│ │ └── NoteList.astro # Build-time Aether OS textpad cards
│ ├── follows/
│ │ └── FollowGrid.astro # Client-side island — paginated follows
│ └── feeds/
│ └── FeedList.astro # Build-time custom feed cards
├── lib/
│ ├── atproto.ts # PDS client: resolve DID, listRecords, typed helpers
│ ├── bluesky.ts # Public API client: getProfile, getAuthorFeed, etc.
│ ├── types.ts # TypeScript types for all AT Proto record schemas
│ └── render.ts # Shared: facet rendering, timeAgo, escapeHtml
├── styles/
│ └── global.css # Impressionist theme: CSS vars, base typography, layout
└── pages/
├── index.astro # Main page: profile + tabbed sections
└── writing/
└── [...slug].astro # Dynamic blog post pages (build-time generated)
Data Flow#
Build-Time (Astro SSG)#
Fetched once at astro build, baked into static HTML:
| Collection | Source | Endpoint |
|---|---|---|
| Profile | Public API | app.bsky.actor.getProfile |
| Blog posts | PDS | com.atproto.repo.listRecords → site.standard.document |
| Publications | PDS | com.atproto.repo.listRecords → site.standard.publication |
| Music tracks | PDS | com.atproto.repo.listRecords → fm.plyr.track |
| Tangled repos | PDS | com.atproto.repo.listRecords → sh.tangled.repo |
| Annotations | PDS | com.atproto.repo.listRecords → at.margin.annotation |
| Notes | PDS | com.atproto.repo.listRecords → computer.aetheros.textpad.text |
| Custom feeds | Public API | app.bsky.feed.getActorFeeds |
PDS resolution: resolve DID from handle via com.atproto.identity.resolveHandle, then look up PDS endpoint from plc.directory/{did}.
Client-Side (Astro Islands)#
Hydrated at runtime for live/paginated data:
| Component | Why client-side |
|---|---|
PostFeed |
Posts change frequently, user expects latest |
ThreadToggle |
Lazy-loaded on click via getPostThread |
FollowGrid |
5800+ follows, needs pagination |
TrackCard |
HTML5 <audio> playback controls need JS |
These use Astro's client:visible or client:idle directives. No framework needed — vanilla JS islands via <script> tags in Astro components.
PDS Client (lib/atproto.ts)#
const DID = 'did:plc:c7frv4rcitff3p2nh7of5bcv';
const HANDLE = 'natespilman.com';
interface ListRecordsResponse<T> {
records: Array<{ uri: string; cid: string; value: T }>;
cursor?: string;
}
async function resolvePDS(did: string): Promise<string> {
const res = await fetch(`https://plc.directory/${did}`);
const doc = await res.json();
const service = doc.service.find(s => s.type === 'AtprotoPersonalDataServer');
return service.serviceEndpoint;
}
async function listAllRecords<T>(
pds: string,
collection: string
): Promise<T[]> {
const records: T[] = [];
let cursor: string | undefined;
do {
const url = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`);
url.searchParams.set('repo', DID);
url.searchParams.set('collection', collection);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url);
const data: ListRecordsResponse<T> = await res.json();
records.push(...data.records.map(r => r.value));
cursor = data.cursor;
} while (cursor);
return records;
}
Build-time calls use this to fetch all records. Client-side islands use the public API directly (no PDS access needed for posts/follows).
Record Types (lib/types.ts)#
Key schemas based on actual PDS data:
interface PlyrTrack {
$type: 'fm.plyr.track';
title: string;
artist: string;
audioUrl: string;
imageUrl?: string;
duration: number; // seconds
createdAt: string;
}
interface LeafletDocument {
$type: 'pub.leaflet.document';
title: string;
author: string;
publishedAt: string;
description?: string;
tags?: string[];
pages: LeafletPage[];
publication?: string;
}
interface LeafletPage {
id: string;
$type: 'pub.leaflet.pages.linearDocument';
blocks: LeafletBlockWrapper[];
}
interface LeafletBlockWrapper {
$type: 'pub.leaflet.pages.linearDocument#block';
block: LeafletBlock;
}
type LeafletBlock =
| { $type: 'pub.leaflet.blocks.text'; plaintext: string; facets?: Facet[] }
| { $type: 'pub.leaflet.blocks.header'; plaintext: string; level: number; facets?: Facet[] }
| { $type: 'pub.leaflet.blocks.code'; plaintext: string; language?: string }
| { $type: 'pub.leaflet.blocks.unorderedList'; children: ListItem[] };
interface TangledRepo {
$type: 'sh.tangled.repo';
name: string;
description?: string;
knot: string;
createdAt: string;
}
interface MarginAnnotation {
$type: 'at.margin.annotation';
body: { value: string; format: string };
target: {
title?: string;
source: string;
selector?: { type: string; exact: string };
};
createdAt: string;
motivation: string;
}
interface AetherosTextpad {
$type: 'computer.aetheros.textpad.text';
title: string;
body: string;
createdAt: string;
updatedAt: string;
}
Blog Rendering Strategy#
Single canonical source: site.standard.document (18 docs). Skip pub.leaflet.document (duplicate subset).
Content format (blog.pckt.block.*)#
blog.pckt.block.text— paragraphs with optional facets (links, bold, etc.)blog.pckt.block.heading— section headings with levelblog.pckt.block.code— code blocks with languageblog.pckt.block.image— embedded images (blob refs)blog.pckt.block.list— ordered/unordered lists
External linking#
Each doc has site (AT URI → site.standard.publication) and path. At build time:
- Fetch all publications, build a map of AT URI →
url - For each doc, construct external URL:
{publication.url}{doc.path} - Render a "Read on {platform}" link on each article card
Display#
- Writing tab: article cards sorted by
publishedAt— title, date, tags, description preview - Individual pages:
/writing/{slug}— full rendered article with "Read on pckt.blog" / "Read on leaflet.pub" link - Code blocks get
<pre><code>with monospace styling
Tab Structure#
Posts | Writing | Music | Repos | Annotations | Notes | Following | Feeds
- Posts: Client-side island. Live Bluesky feed, thread expansion, pagination
- Writing: Build-time. Leaflet blog posts rendered as expandable article cards
- Music: Build-time list + client island for audio playback. Artwork, title, artist,
<audio>element - Repos: Build-time. Cards linking to
tangled.org/natespilman.com/{repo} - Annotations: Build-time. Quote + comment cards linking to source URLs
- Notes: Build-time. Title + body, sorted by updatedAt
- Following: Client-side island. Paginated grid (too many to build-time render)
- Feeds: Build-time. Custom feed generator cards
Audio Player Design#
The TrackCard component uses a client:visible island:
<div class="track-card">
<img src={track.imageUrl} alt="" class="track-artwork" />
<div class="track-info">
<div class="track-title">{track.title}</div>
<div class="track-artist">{track.artist}</div>
<div class="track-duration">{formatDuration(track.duration)}</div>
</div>
<button class="track-play" data-src={track.audioUrl}>Play</button>
</div>
A single shared <audio> element at the page level. Play buttons swap the src and toggle play/pause. No framework needed.
Styling#
Keep the Impressionist theme from the current site:
- Playfair Display + Inter font pairing
- CSS custom properties for the warm palette (lavender, soft-blue, peach, sage, cream)
- Radial gradient background washes
- Soft borders, rounded cards, subtle shadows
global.cssdefines all vars and base styles- Components use scoped
<style>tags in Astro
Build & Deploy#
// package.json
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
}
}
Tangled config#
- Deploy directory:
/dist - Branch:
main
Workflow#
npm run build # Fetches PDS data, generates dist/
git add -A
git commit -m "rebuild"
git push origin main # Tangled auto-deploys from dist/
Staleness & Automated Rebuilds#
Build-time content (blog posts, music, repos) only updates on rebuild. Posts and follows are client-side and always live.
A Tangled Spindle runs on every push to main:
# .tangled/spindle.yaml (or equivalent)
steps:
- name: build
run: npm ci && npm run build
This ensures dist/ is always freshly built from the latest source + PDS data on deploy.
Decisions#
| Decision | Choice | Rationale |
|---|---|---|
| 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. |
| Blog post routing | /writing/{slug} pages |
Each post gets its own page with full rendered content + "Read on {platform}" external link. |
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). |
Skip blue.flashes.feed.post |
Yes | Records contain only timestamps, no text. |
| Skip pixel art rendering | Yes | Raw encoded layer data. Would need a canvas renderer. Low value. |
| PDS endpoint | Resolve dynamically at build | Future-proof if PDS migrates. Cache in build. |
| Syntax highlighting | CSS-only or none | Avoid shipping highlight.js. Use <code> with monospace styling. Can add later. |
Resolved Questions#
- 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-...orhttps://nate-learns-dsa.leaflet.pub/grind-75-.... - Rebuild automation? Yes — configure a Tangled Spindle to rebuild on every push to
main. The Spindle runsnpm run buildand the deploy directory picks updist/. - Standard vs Leaflet docs?
site.standard.documentis the canonical collection (18 docs).pub.leaflet.documentis a subset (15 of the same Grind75 posts in a different block format). Use onlysite.standard.document— skip Leaflet to avoid duplicates.
Publications (external blog URLs)#
| Publication | URL | Content |
|---|---|---|
| Nate Spilman (pckt) | https://pioneer.pckt.blog |
Main blog — "ATProto for normies", etc. |
| Nate learns DSA (leaflet) | https://nate-learns-dsa.leaflet.pub |
Grind75 leetcode series |
Each 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}.