There and back again, the long way round to publishing a blog post
As if I hadn't already written enough about my atproto rabbit hole, this time it's how markdown in my Obsidian vault becomes records in my PDS becomes HTML on a website.
o, last week I posted about why my website is ATmospheric. This one’s about how. The “architecture”. It’s a bit messy, ngl. But it works, and I like it because I feel like I’m actually in control while I’m learning.
Unlike my old Hugo website (Hugo is still great btw), the repo for this site doesn’t contain any blog posts. There’s also no hidden CMS or database connection string; all of it (my posts, my projects & my notes) lives in my PDS at at.bekapod.dev. The site repo decides how I turn all of that stuff into HTML.
It took about two weeks of hyper-focus. I needed to figure out what existing lexicons I wanted to use, how I would design some of my own, and then I needed to figure out how to even read and write data. The plan (if you can even call it a plan) meant that for a little bit, I was fully able to write stuff to my PDS but had no way to read or render it.
How it works
I decided to use Astro for this version of the site, mostly out of curiosity because I’ve never really used it properly before and I know it’s pretty popular. I went with full static generation, so I didn’t have to worry about a server or trying to orchestrate skeleton screens and loading spinners. This means all of my data comes in at build time, which, yeah, means my like counts can be a little out of date, but hey, I don’t get many anyway, so I don’t mind. I could trigger the Netlify build hook every couple of hours if I wanted to, but atm while I’m actively interested in adding things to the site, it generally happens at least once a day anyways.
Another reason that I didn’t go with SSR is that my PDS is Cirrus on Cloudflare Workers, and I want to get a little more comfortable with how much resource the PDS consumes on its own before I start sending additional traffic to it. I’m trying to make the most of the free tier indefinitely, or at least as long as I can!
Reads vs writes
The read path, everything I do at build time, uses no atproto SDK for fetching anything. It’s just fetches against public XRPC endpoints, no auth or anything. I don’t actually even need to tell it where my PDS is; it discovers it the same way as any other atproto client would:
- Resolve my handle to a DID:
com.atproto.identity.resolveHandleon the public Bluesky API. - Fetch the DID document from
plc.directoryand get the#atproto_pdsservice endpoint. - Paginate through
com.atproto.repo.listRecordson that endpoint.
The read client’s nice because if I ever move my account to a different PDS, the site doesn’t care. My handle still resolves, my DID document points somewhere new, and the build just works. Only thing I’d need to update is my netlify.toml, since it allowlists at.bekapod.dev for the image CDN. So if the PDS host changed, I’d have to tweak that.
The write path, my CLIs that put content into my PDS, is obviously fully authenticated, using @atcute (@atcute/client with a password session) versions of createRecord, putRecord, uploadBlob. The writes only ever happen on my machine, so the website codebase and Netlify hold no credentials.
Lexicons as a content model
Since I’m feeding the site from my PDS, the content model is lexicons, not a database schema or frontmatter conventions. I’m using four different namespaces:
site.standard.document
My blog posts use the standard.site lexicon for long-form publishing, with types imported from npm. Same lexicon as leaflet.pub, pckt.blog, offprint.app. So my posts even show up on some of those, and in other apps like Standard Reader.
I’ve also got a site.standard.publication record that describes the site itself. So tools can discover that too.
at.markpub.markdown
The actual post content in each document is markdown, defined by markpub.at. I’ve vendored this one instead of installing it. I use GitHub-flavoured Markdown so I can have things like checklists and callouts.
dev.bekapod.project
This one powers the projects & making grids on my homepage. It’s just one lexicon, and I sort it into the section I want based on a type enum.
If this were a public lexicon, I probably wouldn’t have used an enum. Widening the enum would be a breaking change. (I may or may not have internalised trauma from breaking an enum IRL.) But I don’t expect anyone but me to ever read this content. Same goes for field semantics; if it were public, I couldn’t just rename a column later. Avoiding breaking changes is hard and annoying, but, as good citizens of the web, we must do our bit.
app.bsky.feed.post
This is my notes feed, which is just my Bluesky posts, so I’m not really using this lexicon directly. I do a bunch of filtering; exclude my replies, reposts, and blog post announcements (since you can see the blog posts right below that section anyway).
Rendering records into pages
At build time, every record gets parsed against its lexicon schema before I try to make HTML from it. If any records fail parsing, I just skip them for now, so a malformed record won’t break my build. I’m fine with this because, aside from the Bluesky notes, I’m the only one adding this stuff to my PDS.
Once a document’s validated, it goes through a little custom pipeline: Shiki syntax highlighting themed to match the site, and hast plugins for GitHub-style callouts and checklists.
I thought images might be annoying, but they were fine. Images in posts are blobs on the PDS, so any image URL pointing at com.atproto.sync.getBlob gets wrapped in Netlify’s image CDN with a responsive srcset. My PDS is allowlisted as a remote image host in my netlify.toml.
Parsing Bluesky posts
Notes take a different path because Bluesky posts aren’t markdown. Rich text in app.bsky.feed.post records comes as facets (atproto’s rich-text annotations, byte ranges over the post text marking links, mentions and hashtags), so a post is plain text plus a list of instructions. For a quick explanation, I encode the post text into UTF-8 bytes, and slice it into segments using those byte ranges:
- Link facets go to the link URL (obviously).
- Hashtags link to the tag on Bluesky.
- Mentions link to the person's profile.
If a facet has weird offsets (out of range or overlapping) or is an unknown type, I just drop it to plain text. Bit of defensive programming for you.
Images were easy by comparison. The feed response includes a view of each post’s image embed, so I can just use Bluesky’s CDN thumbnail, alt text, and aspect ratio directly.
Counting things not in my PDS
Beside each post, I print the number of Bluesky likes and standard.site recommendations. But that data isn’t in my PDS; it lives in other people’s PDSes.
A like is an app.bsky.feed.like record in the liker’s repo and a recommendation is a site.standard.graph.recommend record in the recommender’s repo.
For likes, Bluesky’s AppView already aggregates them. Each post has a Bluesky post reference, so during build I batch those URIs into app.bsky.feed.getPosts calls and read the like counts off each response.
Recommendations don’t have an AppView, but I found Constellation, a backlink index for atproto. You give it a target (the post’s at-URI), a collection (site.standard.graph.recommend), and a record path (.document), and it tells you how many records across the network point at that target. You can use the distinct-dids count instead of the raw record count, so you’re not counting the same person twice.
A few notes:
- Constellation doesn’t have a batch endpoint, so it’s one request per post at build time. I group them into bundles of five concurrent requests at a time.
- Those fetches run in parallel with the Bluesky engagement lookups since they don’t depend on each other.
- Both kinds of counts are just decoration, not important content. So if a lookup fails, the build still goes through.
- Yeah, all of this is basically frozen at build time. I don’t trigger rebuilds on likes or recommendations. The counts are only as fresh as the last deploy. I don’t mind, since I’m deploying pretty often atm anyway. Maybe I’ll do something cleverer later.
Actually publishing stuff
Posts are written in Markdown in my Obsidian vault, not in the GitHub repo.
Previewing
I’ve got a little drafts integration that watches my vault folder (outside the project root) and renders each draft at a URL like /blog/preview/[slug] through the same pipeline as a published post. Same processing and components, except local images are served by a dev-only endpoint since they’re not blobs yet.
Publishing
My CLI tool pnpm pds:publish <post-dir> parses the frontmatter, uploads any images as blobs (with a 1MB size guard because that’s the lexicon’s limit), rewrites the image links to blob URLs, builds the site.standard.document record, and writes it to the PDS.
Blobs are also attached to the record itself as a blobs array. If I don't do this, the PDS could garbage-collect my images which would be sad. A blob URL inside markdown text isn't really a real reference.
Once a post is published to my PDS, I fire off a Netlify build hook, which gets it onto my site.
On first publish, the record’s generated rkey (record key, its ID within the collection) is written back onto the source file’s frontmatter. This means that next time I “publish” that post, it finds the rkey and updates it in place instead of creating a duplicate. If writing the rkey back fails, it fails very obviously with the exact line to add manually, because the record now exists and if I miss adding the rkey I’d end up with a load of duplicates that I don’t want.
Updating a record replaces the whole value, so editing an already-announced post would inadvertently delete fields like the bskyPostRef that get written in a later step. So, to make this simple, I just fetch the existing record first and make sure bskyPostRef and ogImage are copied over to the updated record.
Announcing
Again, my own CLI tool, pnpm pds:announce <post-dir>, creates a Bluesky post that links to the article. It guards against running twice (if the document already has a bskyPostRef, it stops). It also won’t “announce” a post that doesn’t exist (by fetching the post URL and erroring if it’s not a 200). This is mostly to guard against me publishing and announcing in quick succession (before the build process has finished).
Once the announcement script is happy to actually make an announcement, I take a screenshot of the live page using Playwright and upload that as a blob (I’m saying blob a lot here). That screenshot becomes the thumbnail on the Bluesky link card. It also gets written back onto the document record as its ogImage.
The post text tries to respect Bluesky’s 300-grapheme limit using Intl.Segmenter, because if you’ve ever done LeetCode or Exercism or the like, you already know that graphemes and characters aren’t the same (…emojis).
I also learned that to get the little publication record footer on the Bluesky embed, I need to include strong refs to the document and publication record on the Bluesky post itself (the embed’s associatedRefs). The AppView won’t follow the document’s site field. I only just fixed this, and I’m not sure if it works yet. I’ll find out after I publish this and update here if it did. (And I guess I’ll also be proving I can update a post after it’s live.)
It worked! You can see my new and improved embed over here.
Announcing is my least favourite part, tbh. I overthink it and worry about spamming my feed with links to blog posts no one’s bothered about, just so I can keep things tidy. But that’s a me problem.
Current state & what is next??
The current state, as you might’ve guessed, is super manual. A better person than me would’ve done this all in a much smoother way from the start. But, idk, sometimes it’s good to actually feel where the pain is in a process like this. It’s less magical when I come back after a break, which means I’m more likely to remember how it works.
I think my next steps for this site are tryna include some more fun stuff. Like, wouldn’t it be fun to have a guestbook or a hit counter? The typical personal website “furniture”. I kind’ve wanna research & write about that now. Eventually, I guess I’ll also have to build a page to list/paginate blog posts because I don’t think I can just keep an infinite list on my homepage.
Maybe some sound effects would be cute. Maybe custom cursors to match my new pixelly theme…I have work to do!
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.