Skip to content

Pushing this blog to ATProto through standard.site

Dan Arel
Aug 25, 20265 min read1 read

Recently, I started blogging with Leaflet and loved the experience, except that I had this personal blog set up, with some cool pages I didn't think I could easily recreate there, and nothing is better than full control of every aspect of your site.

What I did want, however, was the ability to continue to publish my posts with ATProto and allow people to subscribe on Standard Reader, since I am less concerned with an email list.

I do currently republish all of my Buddhist posts on my Substack, and for now that will remain the same, but someday I would like to have the readership to move that elsewhere as well.

So, I decided to leave Leaflet behind and move all my posts to this site, but then to set up a nice way to share them and store them in my PDS.

How it works

Standard.site is a pair of ATProto lexicons: site.standard.publication, which describes the blog itself (name, URL, icon), and site.standard.document, one record per post, pointing back at that publication. Every record lives in my own PDS under my DID, not in some third-party database. Ownership gets proven the same way domain ownership always has: a /.well-known/site.standard.publication file on danarel.com holding the publication's at:// URI, and a matching link tag in each post's HTML pointing at that post's document record. A reader (Bluesky, docs.surf, pckt, or anything else that speaks ATProto) can follow either direction and confirm the two point at each other.

The blog itself was running on ATProto at tech.danarel.com, using Leaflet. The rest of danarel.com, including this post, is a static Hugo site, and I wanted its posts to show up as real ATProto records too, without moving the writing off Hugo. There are existing tools that automate this (Sequoia is one), but I wanted the publishing step to be a plain script I could read top to bottom and change without learning someone else's config format, and I wanted full control over exactly what gets pushed to the PDS on each run. So instead of adopting a CLI, I wrote a small Node script that reads a post's Hugo front matter and pushes the record directly.

The publish script

The script takes a path to a Hugo markdown file, reads its front matter with gray-matter, and either creates or updates the matching site.standard.document record via @atproto/api. A local JSON mapping file (data/atproto-records.json) tracks which Hugo post maps to which at:// URI, so re-running the script against an already-published post updates the existing record instead of creating a duplicate:

import { AtpAgent } from "@atproto/api";
import matter from "gray-matter";

const agent = new AtpAgent({ service: ATPROTO_SERVICE });
await agent.login({
  identifier: ATPROTO_HANDLE,
  password: ATPROTO_APP_PASSWORD,
});

const { data: frontMatter, content: body } = matter(raw);

const record = {
  $type: "site.standard.document",
  site: PUBLICATION_AT_URI,
  path: urlPath,
  title: frontMatter.title,
  description: frontMatter.description || undefined,
  publishedAt,
  tags: frontMatter.tags || frontMatter.topics || undefined,
  textContent: frontMatter.description || body.trim(),
  coverImage,
  content: {
    $type: "at.markpub.markdown",
    text: { $type: "at.markpub.text", markdown: body.trim() },
  },
};

content uses the Markpub markdown format, so the full post body travels with the record instead of just a title and link. Any key left undefined (a post with no description, say) gets stripped before the record is sent, since the API rejects records with explicit undefined values.

Whether the script creates or updates depends on whether the mapping file already has a URI for that post's slug. If it does, putRecord overwrites the existing record at that rkey. If it doesn't, the script first checks the PDS itself for a record with a matching path, in case the mapping file is out of sync, and only creates a new one if nothing turns up:

let uri = mapping[slug]?.uri;

if (!uri) {
  const existing = await agent.com.atproto.repo.listRecords({
    repo: did,
    collection: "site.standard.document",
    limit: 100,
  });
  uri = existing.data.records.find((r) => r.value?.path === urlPath)?.uri;
}

if (uri) {
  await agent.com.atproto.repo.putRecord({
    repo: did,
    collection: "site.standard.document",
    rkey: uri.split("/").pop(),
    record,
  });
} else {
  const res = await agent.com.atproto.repo.createRecord({
    repo: did,
    collection: "site.standard.document",
    record,
  });
  uri = res.data.uri;
}

Cover images and previews

The first version of the record had no image, so shared links rendered as plain text on Bluesky. Fixing that meant uploading the post's header image as a blob and attaching the blob reference, not a URL, as coverImage:

if (frontMatter.image) {
  const bytes = fs.readFileSync(imagePath);
  const upload = await agent.com.atproto.repo.uploadBlob(bytes, {
    encoding: mimeType,
  });
  coverImage = upload.data.blob;
}

ATProto records reference media by blob, not by hotlinking an external URL, so this step is what actually makes the rich preview card show up instead of a bare link.

Wiring it into the site

Two small pieces glue the Hugo site to the PDS:

  • The publication record's url points at danarel.com, and the /.well-known/site.standard.publication verification file is served from danarel.com itself. Keeping the verification on the root domain is what lets readers trust that posts under danarel.com are mine.
  • Each post's template gets a site.standard.document link tag pointing at that post's record once it's been published, so a reader landing on the HTML page can resolve straight to the ATProto record.

Publishing a post is fully automated. A GitHub Actions workflow runs on every push to main, finds the post files that changed, and runs the publish script against each one, so a site.standard.document record gets created or updated as part of the same push that deploys the Hugo site. Writing and publishing a post is just git push, nothing else.

Did you enjoy this article?

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

Across the AtmosphereDiscussions