Skip to content

Publishing your blog to standard.site in Elixir

A walkthrough for posting your Elixir/Phoenix blog to Bluesky and the standard.site network, using a small mix task and an atproto client.

standard.site is an AT Protocol schema for long-form publishing that lets blogs expose their posts as records, so readers, indexers, and Bluesky can find and render them across the network. There’s a lot of stuff being built on top of this right now, but you can already start publishing things. Publishing your blog posts has the cute little upside of your Bluesky link previews getting a special CTA, because Bluesky automatically pulls the information from standard.site.

On top of that, once you’ve published your records, you can view them in atproto explorer, in pdsls.dev, and they’ll automatically be aggregated in several places like docs.surf. To read more about why federated content is cool, Mat Marquis goes into more detail here.

You can use pdsls.dev, signing in to your account, to manually create records. But it'd be more fun to do it in Elixir and start working towards automation! I wanted to get something up and running without too much complexity, so I went for an approach where I manually publish my posts using a mix command. So the end result we’re aiming for is being able to run something like mix atproto.publish <slug>, and have that record point at the live post on this website.

Okay, let’s look at some code!

A very basic atproto client

I spent a day exploring this and I ended up with a basic atproto client and some simple niceties. I made a deliberate effort not to DRY things up too much, I’m very much in the camp of “most abstractions are premature”. I’d rather feel the pain a little bit before I get too clever.

To be able to publish our records we need a PDS, a Personal Data Server. You’re free to set up your own, it’s basically your home in the atproto universe, where your data lives. You’ll also need an account. I’ll be using my main Bluesky account, jola.dev, and the main Bluesky API endpoint https://bsky.social/xrpc. Armed with an identifier and a password, we’re ready to go.

We’re going to be implementing an atproto client with 4 different operations: login, resolve_handle, create_publication, and publish_document.

Let’s start with resolve_handle. We only need to run this once really, it will turn our handle, like jola.dev, into a did, a permanent unique identifier. If you already know your did, you can skip this.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

Run that in IEx and you should get your did back. Make a note of it and let’s continue with login. Oh, and if you’re self-hosting or for some reason you’re not sure where your PDS is, you can use the did to discover it using plc.directory. If you know where to direct your requests, you can actually skip this step because login also returns the did. But nice to know! Back to the client operations.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

We actually only need access_token here, but I imagine I’ll use the other fields too in the future so I’m “documenting” them for myself here.

We can take a little break here and try it out in IEx.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

Excellent! Now we have an access_token and we’re basically unstoppable. Let’s sketch out the remaining operations before we implement the helpers we need.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

create_publication we’ll use to create our… well, publication. This would be your website! And then publish_document is for publishing each blog post. Excellent. Let’s continue. In the interest of time (?) I’m going to drop a big chunk of code on you here, but it’s just plumbing. We need to shape the request payloads for the atproto API, we need a helper for uploading our blog post preview images, and then some tidying up date times.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

That’s it for the client! Let’s take a closer look at the data we’re sending.

The shape of the records

Structs are a great way to bring some compile time hints and support to your developer experience, so let’s add some!

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

And the main piece of glue, I’ve chosen to organize like this.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

Everything is coming together! We can now turn a NimblePublisher post into a Document for our atproto client, and we’ve got our site definition, aka Publication.

Proving you’re you

Time for a little interlude. We’ve been looking at creating publications and documents in the standard.site schema, but how does the atproto ecosystem prevent just anyone from publishing things in your name? After all, it’s just accepting the url and other fields that you’re providing.

The first piece of the puzzle is /.well-known/site.standard.publication. You put this record on your site to prove that your publication is legit. Let’s set up a little route and controller for it.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

And the router needs something like get "/.well-known/site.standard.publication", WellKnownController, :publication. That’s the publication covered, but what about the documents?

Just like Mastodon, we can use a link tag in the head tag of our blog post to prove ourselves. Let’s add a little section to our root.html.heex in the head section.

[object Object],[object Object],[object Object]

So we’re using the document_uri function we just defined to build the full URI of the post, as atproto would expect to find it. You’ll only want to do this on pages that have posts, which is why I’ve put an if statement around it.

We’re finally ready to start publishing!

Actually publishing documents

As mentioned I went for a simple mix task. It makes it a manual process, but I can live with having to manually publish these after posting to my blog. For now anyway, I’m sure I’ll end up doing something clever eventually. But first, let’s tackle the publication itself, since it’s just a one off and it needs to exist first. I ended up just executing this in IEx.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

That’s it. You can see the record live in the atproto explorer.

But documents is more a repetitive task, so this is where the mix task comes in.

[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]

Let’s try it out.

[object Object],[object Object]

And the record is live!

Do it automatically?

I hope that’s been useful and at least vaguely interesting. I’m very curious to see where all this will lead. I doubt you’ll be able to just copy paste everything that I have here and that it’ll just work for you, although I guess if you’ve set up NimblePublisher the same way I have, it might! But it should provide the blueprint for you to set things up for yourself.

In the post I’ve cut some corners, it’s already a lot of code. One of those corners is that atproto documents support textContent, so you can publish the content of your post, meaning the whole thing lives fully on the PDS, which is cool. The full version is available on Github.

I know I said I don’t want to get clever about this, but I have been thinking about approaches to automatically publishing new blog posts. I might want to do something where, at deploy time I compare what’s published and what’s not, and then do some reconciliation? We’ll see where I end up! Thanks for reading!

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions