In this blog post, we will explain how to generate the outbox and notes, ready to be shared in the Fediverse.

You can also navigate the other parts of this series here.

Overview

In the previous blog post, we created the actor endpoint as a static file. As explained there, this file has a pointer to the outbox, which is a collection of notes. A note in the ActivityPub protocol represents an activity, such as a toot, photo, comment, etc. (hence the “Activity” in ActivityPub), and in our case, it will be the representation of a blog post.

A Deep Dive into the Structure of Outbox and Notes

To generate the outbox and the notes from our static website, there are a few alternatives. The outbox looks like this:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://maho.dev/socialweb/outbox",
  "type": "OrderedCollection",
  "summary": "Bite-sized pieces of Software Engineering from a Garbage Code Connoisseur by Maho Pacheco",
  "totalItems": 24,
  "orderedItems": [
     // ... collection of notes
  ]
}

Note: The Mastodon implementation returns a paginated object. The ordered collection contains not the items themselves but links to where to obtain such items. For a blog site with fewer than ~100 notes, this does not represent a problem. When I hit the 100 mark, I may implement pagination in my outbox generation.

A note will look like this:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://maho.dev/socialweb/notes/1dff22b5faf3fbebc5aaf2bb5b5dbe2c",
  "type": "Note",
  "content": "The Gendered Lens of AI: Unpacking Bias in Language  ... html content",
  "url": "https://maho.dev/2024/02/the-gendered-lens-of-ai-unpacking-bias-in-language-models/",
  "attributedTo": "https://maho.dev/@blog",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [],
  "published": "2024-02-18T21:06:38-08:00",
  "tag": [
    {
      "Type": "Mention",
      "Href": "https://hachyderm.io/users/mapache",
      "Name": "@mapache@hachyderm.io"
    },
    {
      "Type": "Hashtag",
      "Href": "https://maho.dev/tags/ai",
      "Name": "#ai"
    },
    ...
  ],
  "replies": {
    "id": "https://maho.dev/socialweb/replies/1dff22b5faf3fbebc5aaf2bb5b5dbe2c",
    "type": "Collection",
    "first": {
      "type": "CollectionPage",
      "next": "https://maho.dev/socialweb/replies/1dff22b5faf3fbebc5aaf2bb5b5dbe2c?page=true",
      "partOf": "https://maho.dev/socialweb/replies/1dff22b5faf3fbebc5aaf2bb5b5dbe2c",
      "items": []
    }
  }
}

There are a few things to dissect here:

  1. The id is a unique URL per blog post where to obtain the note json when a request with Accept: activity+json is made. In our case, it is the static location where such JSON lives.
  2. Content is HTML content. Mastodon will sanitize it to avoid any HTML injection, so you cannot include things like iframes.
  3. The url field will contain the actual blog post URL in a human-readable way.
  4. The attributedTo field points to the actor endpoint.
  5. The tag collection is used by Mastodon to show tags and mentions and must match what the HTML content has.
  6. The replies section is an optional set of endpoints where we can retrieve a collection of replies/comments to the blog post. We will see how to implement these endpoints in following posts.

Generating Outbox and Notes

As I mentioned before, there are a few alternatives. If you are using Hugo, Paul Kinlan uses a Hugo template to generate these notes.

However, I decided to do something more generic, in case your site is not using Hugo. This is where RSS comes to light. RSS (Really Simple Syndication) is basically an XML representation of an outbox created for news websites or blogs. Most, if not all, the static web generators generate an RSS by default, so I leveraged that and created a tool, Rss2Outbox, to do this conversion.

  1. Download the ActivityPub Utils package from here for your Windows/Linux/OSX environment and add it to your PATH.
  2. Build your site (e.g., hugo).
  3. Execute the RSS2Outbox tool in a terminal; it will show you the usage help:
Description:

Usage:
  Rss2Outbox [options]

Options:
  --rssPath <rssPath> (REQUIRED)                The path to the RSS feed, usually a local path to index.xml
  --staticPath <staticPath> (REQUIRED)          The path to the static folder where the outbox and notes will be
                                                generated
  --authorUsername <authorUsername> (REQUIRED)  The author username if the human publishing the blog, e.g.,
                                                @mapache@hachyderm.io
  --siteActorUri <siteActorUri> (REQUIRED)      The URI of the author (actor endpoint) of the blog, e.g.,
                                                https://maho.dev/@blog
  --domain <domain>                             The domain of the blog; if not provided, it will be extracted from the
                                                RSS feed
  --authorUri <authorUri>                       The author URI if the human

 publishing the blog, e.g.,
                                                https://hachyderm.io/users/mapache. If not provided, it will be guessed from the
                                                authorUsername.
  --version                                     Show version information
  -?, -h, --help                                Show help and usage information

Five parameters are mandatory, as explained. In my case, my execution looks like this from the root Hugo folder:

Rss2Outbox \
    --rssPath "public/index.xml" \
    --staticPath static \
    --authorUsername "\@mapache@hachyderm.io" \
    --siteActorUri "https://maho.dev/@blog" \
     --domain "https://maho.dev"

A confirmation message will let you know that the outbox and notes were created in the static/socialweb/outbox and static/socialweb/notes folders.

At the time of writing this post, I have already thought of a few improvements I want to make. For example, being able to retrieve the RSS.xml from a URL, configure the content template, or even implement all these guides in a GitHub template ready to clone and be used. However, all your feedback is very welcome to help me understand where to focus my efforts.

  1. Publishing your notes. Don’t forget that if you are using Hugo, you probably need to run hugo again to copy all these static artifacts into the public folder. After that, you can deploy your assets to the cloud container, and that’s it! You have notes ready to be shared.

How to Test

Now, if you follow your account in Mastodon, you will be able to see the number of posts written by such an account. This is coming from the outbox. Not all Mastodon accounts will retrieve these posts; this is a configuration by instance. So, some instances import all the notes when you follow the account, but others will not.

However, your notes are now ready to be shared in Fediverse! You can test it by grabbing one of the URLs and searching in any Mastodon instance such URL. It will retrieve the post, and you can interact with it by boosting it or favoriting it.

Searching a URL in Mastodon

This action will “import” such note to that mastodon instance, and will become part of the Fediverse. Also worth to note that each interaction (like, boost, reply) to the note is an activity and will generate a POST request to your inbox. At this point, we are still not ready to show our site to the Fediverse because we will lose these interactions, but we are closer.

In the next blog post, I will explain how to make your static site handle these interactions and follow requests in the inbox. If you’re interested, follow @blog@maho.dev in the Fediverse and let me know what you think!

A few notes on the cards format

Posts in mastodon use certain methods to render a preview of the links shared. The method I am using is through metadata tags in the header, so if you want your posts show nicely make sure these give exists:

  <meta property="og:type" content="article">
  <meta property="og:title" content="{{ .Title }}">
  <meta property="og:description" content="{{ .Summary | plainify }}">
  <meta property="og:url" content="{{ .Permalink }}">
  <meta property="og:image" content="{{ .Site.BaseURL }}{{ .Params.cover }}" />