this repo has no description
0
fork

Configure Feed

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

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.listRecordssite.standard.document
Publications PDS com.atproto.repo.listRecordssite.standard.publication
Music tracks PDS com.atproto.repo.listRecordsfm.plyr.track
Tangled repos PDS com.atproto.repo.listRecordssh.tangled.repo
Annotations PDS com.atproto.repo.listRecordsat.margin.annotation
Notes PDS com.atproto.repo.listRecordscomputer.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 level
  • blog.pckt.block.code — code blocks with language
  • blog.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:

  1. Fetch all publications, build a map of AT URI → url
  2. For each doc, construct external URL: {publication.url}{doc.path}
  3. 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.css defines 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#

  1. 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-....
  2. 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/.
  3. 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.

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}.