# name
How much ActivityPub can a Static Site Generator implement?

# nameMap.de
Wie viel ActivityPub kann ein Static Site Generator?

# nameMap.en
How much ActivityPub can a Static Site Generator implement?

# content
There already are multiple blogging solutions which are part of the Fediverse. Among them are dedicated Fediverse blogs such as Plume or WriteFreely, but there also are plugins which retrofit existing CMS, e.g. for Wordpress or Drupal.

ActivityPub, the network protocol behind the Fediverse can only be fully implemented by means of an active server component: Among other things, incoming messages delivered to inboxes have to be processed. Sometimes they need to be forwarded, and outgoing messages need to be signed.

Figure 1: The basic mechanisms behind ActivityPub: Users publish resources in their outbox, and retrieve received resources from their inbox.

Nevertheless I wanted to figure out, which parts of the ActivityPub protocol can be implemented in a purely static website, and how well other servers in the Fediverse interact with it. My goal was to attach this blog to the Fediverse. The blog is generated using the static site generator software Pelican.

Metadata Endpoints

One prerequisite for implementing ActivityPub are multiple static metadata endpoints, with which a server signals whether it supports ActivityPub, and under which HTTP endpoints the various ActivityPub resources are reachable:

/.well-known/nodeinfo simply links to the "real" nodeinfo endpoint:

{
  "links": [
    {
      "href": "https://s3lph.me/activitypub/nodeinfo",
      "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0"
    }
  ]
}

Behind the linked path (which can be chosen freely), the global metadata of this ActivityPub instance is published:

{
  "version": "2.0",
  "software": {
    "name": "pelican-activitypub",
    "version": "0.1"
  },
  "protocols": [
    "activitypub"
  ],
  "services": {
    "inbound": [],
    "outbound": [
      "atom1.0",
      "rss2.0"
    ]
  },
  "openRegistrations": false,
  "usage": {
    "users": {
      "total": 1
    },
    "localPosts": 27
  },
  "metadata": {
    "nodeName": "s3lph made"
  }
}

This JSON document describes the server, however we still need to discover the individual users of this instance. This is where the webfinger endpoint comes in. Usually it is found at /.well-known/webfinger, but some pieces of software insist on first resolving this path using the /.well-known/host-meta endpoint:

<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  <Link rel="lrdd" template="https://s3lph.me/.well-known/webfinger?resource={uri}" type="application/xrd+xml" />
</XRD>

Now we already stumbled over the fist obstacle preventing a purely static implementation: The name of the user to be resolved is passed as an URL parameter, which needs to be handled by a HTTP server.

There are two possible workarounds:

If there only is a single user on the server, the parameter can be ignored. Instead, always the same static response is returned, no matter the URL parameter.

If there are multiple users, we can generate a static webfinger endpoint per user. This requires the webserver to be configured to redirect the regular webfinger endpoint to these user specific endpoints.

For example, using the Apache webserver, this can be achieved like this:

RewriteEngine on
RewriteRule ^/.well-known/webfinger?resource=acct:([^@]+)@s3lph.me$ /.well-known/_webfinger/$1 [L]

The webfinger endpoint links to the actual user resources:

{
  "subject": "acct:s3lph@s3lph.me",
  "aliases": [
    "https://s3lph.me/author/s3lph.html",
    "https://s3lph.me/activitypub/users/s3lph"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://s3lph.me/author/s3lph.html"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://s3lph.me/activitypub/users/s3lph"
    }
  ]
}

In this case, the author @s3lph@s3lph.me is resolved to two alias URLs: The author's feed in the blog, as well as the ActivityPub Person resource.

ActivityPub: Persons and Articles

Now that we can resolve usernames such as @s3lph@s3lph.me to ActivityPub URLs such as https://s3lph.me/activitypub/users/s3lph, we can take a closer look at the Person resource behind this URL:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],
  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog.",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},
  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],
  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",
  "discoverable": true,
  "manuallyApprovesFollowers": true,
  "published": "2020-02-05T01:36:00+01:00",
  "updated": "2022-11-12T14:05:23Z",
  "endpoints": {
    "sharedInbox": "https://s3lph.me/activitypub/collections/inbox/s3lph"
  },
}

This resource is a bit bigger than before, so let's look at it piece by piece. At first the schema of this JSON document is described:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],

Next the basic information about the person are described. These are used by ActivityPub-Clients to display the user's profile page:

  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog...",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},

Some additional metadata can be provided:

  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],

"PropertyValue" attachments are rendered as tables on the user's profile page by most ActivityPub clients. tags list attributes such as hashtags and mentions of other users within the profile's description, so that they can be easily indexed by other servers in the Fediverse.

movedTo and alsoKnownAs are used to indicate that the account has moved, and the new account should be followed instead.

With all of this, we get a user profile that can be viewed on Fediverse client applications, such as the Mastodon app "Tusky":

Figure 2: The profile of @s3lph@s3lph.me, as shown in the Mastodon app Tusky

Finally, the URLs to linked ActivityPub resources have to be provided:

  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",

I've already mentioned the relevance of the inbox and outbox before. Since this implementation does not process inboxes, there only is an empty colllection (list of ActivityPub resources) behind the inbox URL. The same goes for the "following" and "followers" collections:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "OrderedCollection",
  "id": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "totalItems": 0,
  "orderedItems": []
}

The outbox is a collection as well, containing the articles published by this user. One of these articles (actually this one) looks like this:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "Article",
  "id": "https://s3lph.me/activitypub/posts/activitypub-static-site",
  "published": "2022-11-17T02:00:00+01:00",
  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],
  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,
  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]
}

The beginning of an article is more or less equal to that of a person, so I won't be repeating it here.

Next the relationships to other resources are described, e.g. who wrote the article, and to whom it is addressed. The special URL https://www.w3.org/ns/activitystreams#Public describes that the article is public, and should e.g. be listed in global timelines:

  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],

Title and content of the article can be multilingual. However, the contentMap und nameMap attributes are only supported by a few ActivityPub servers or clients. Most others simply always show the untranslated default content:

  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,

Finally, same as with persons, additional data like tags and mentions of other users are listed in a machine readable form:

  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]

Sow now we have modelled authors and their articles as ActivityPub resources. This is also everything which can be reasonably implemented as a purely static site.

The code for generating these ActivityPub resources is available as a Pelican plugin. However, before you go ahead and install this on your own Pelican blog, I'd advise you to finish reading this article.

Compatibility to Fediverse Services

For testing my implementation, I set up three different Fediverse instances to interact with my blog: Mastodon, Pleroma and Misskey.

All three services are able to retrieve and show the profile @s3lph@s3lph.me, and the number of blog articles is shown. However, the articles themselves are not shown. As it turns out, articles from other instances are usually not retrieved automatically.

The articles would only be shown if they were POSTed from their origin instance to the target instance. Alternatively, all three test instances were able to show the articles by pasting the article's URL into the search bar. Afterwards these articles were also shown in the timeline of their author.

Unfortunately, this is almost everything that can be achieved with a purely static implementation. Especially the following important functions are not available:

Following: If Alice wants to follow the Fediverse account of Bob, she has to send a follow request to Bob. Bob (or his instance) has to confirm this request before it becomes effective.

Replies: If Alice wants to reply to a message of Bob, her response is only visible on Alice's instance at first. The response will also be posted to the inbox of Bob, and it would be in his instance's responsibility to forward the reply to all other involved instances. This crucial step is not possible with a static ActivityPub endpoint.

Deletion: If an article ha been cached by another instance, it is usually kept in the cache until it's explicitly deleted by its origin instance. For this, a cryptographically signed deletion request would have to be submitted to the instance's inbox.

Updates: The same limitations that apply to deletions apply to updates as well. In fact, this list item will never show up on instances that already have an earlier version of this article in their cache.

What does work are likes and boosts, tough they do not increment the counters under an article.

Additionally, it turns out that e.g. Mastodon only shows a link preview to the article, since the Article object type is not fully supported; the Note type is used for short messages. However, both Pleroma and Misskey show the full article natively.

Conclusion

The question "How much ActivityPub can a Static Site Generator implement?" can thus be best answered as "quite a lot, but not enough to be relevant in practice." Even for this blog, the purely static ActivityPub implementation will most likely not have any relevance, however the articles will presumably keep being published on the Fediverse in this limited form.

PS: If you're reading this article on the Fediverse and want to write a reply, please (additionally) address it to @s3lph@chaos.social, otherwise I won't see it.

# contentMap.de
Es gibt bereits verschiedene Blog-Lösungen, die Teil des Fediverse sind: Darunter sind sowohl dedizierte Fediverse-Blogs wie Plume und WriteFreely, aber es gibt auch Plugins, die bestehende CMS nachrüsten, z.B. für Wordpress oder für Drupal.

ActivityPub, das Netzwerk-Protokoll hinter dem Fediverse, lässt sich nur mit einer aktiven Serverkomponente vollständig umsetzen: Unter anderem muss auf eingehende Nachrichten in den Inboxen reagiert werden, teilweise müssen diese auch an andere Server weitergeleitet werden, und ausgehende Nachrichten müssen zeitnah signiert werden.

Abbildung 1: Die grundsätzlichen Mechanismen hinter ActivityPub: User veröffentlichen Ressourcen in ihrer eigenen Outbox, und rufen empfangene Ressourcen aus ihrer Inbox ab.

Trotzdem wollte ich herausfinden, welche Teile des ActivityPub-Protokolls mit einer rein statischen Website implementiert werden können, und wie gut andere Server im Fediverse damit umgehen können. Mein Ziel war, meinen Blog hier ans Fediverse zu hängen. Der Blog wird mit der Static Site Generator-Software Pelican erzeugt.

Metadaten-Endpunkte

Eine Grundvoraussetzung, um ActivityPub zu implementieren, sind diverse statische Metadaten-Endpunkte, mit denen der Server signalisiert, dass er ActivityPub unterstützt, und unter welchen HTTP-Endpunkten die verschiedenen ActivityPub-Ressourcen zu finden sind:

/.well-known/nodeinfo verlinkt einfach nur auf den "echten" nodeinfo-Endpunkt:

{
  "links": [
    {
      "href": "https://s3lph.me/activitypub/nodeinfo",
      "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0"
    }
  ]
}

Unter dem verlinkten Pfad (der frei gewählt werden kann), werden die globalen Metadaten der ActivityPub-Instanz publiziert:

{
  "version": "2.0",
  "software": {
    "name": "pelican-activitypub",
    "version": "0.1"
  },
  "protocols": [
    "activitypub"
  ],
  "services": {
    "inbound": [],
    "outbound": [
      "atom1.0",
      "rss2.0"
    ]
  },
  "openRegistrations": false,
  "usage": {
    "users": {
      "total": 1
    },
    "localPosts": 27
  },
  "metadata": {
    "nodeName": "s3lph made"
  }
}

Mit diesem JSON-Dokument wird der Server an sich beschrieben, nun müssen aber noch die einzelnen User der Instanz gefunden werden. Hierzu dient der webfinger-Endpunkt. Dieser ist zwar üblicherweise unter /.well-known/webfinger zu finden, aber einzelne Softwarelösungen bestehen trotzdem darauf, den Pfad erst unter einem anderen Endpunkt nachzuschlagen, und zwar unter /.well-known/host-meta:

<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  <Link rel="lrdd" template="https://s3lph.me/.well-known/webfinger?resource={uri}" type="application/xrd+xml" />
</XRD>

Hier sehen wir schon das erste Hindernis, das eine rein statische Implementation verhindert: Der Name des aufzulösenden Users wird als URL-Parameter übergeben, der durch einen HTTP-Server behandelt werden muss.

Hierzu gibt es zwei mögliche Lösungen:

Wenn es auf dem Server nur einen einzelnen User gibt, kann der Parameter eigentlich ignoriert werden. Stattdessen wird einfach immer die gleiche, statische Antwort zurückgegeben.

Wenn es mehrere User gibt, kann für jeden User ein statischer webfinger-Endpunkt generiert werden. Dies setzt aber voraus, dass der Webserver konfiguriert wird, den eigentlichen webfinger-Endpunkt entsprechend umgeleitet werd.

Dies lässt sich z.B. im Apache-Webserver so umsetzen:

RewriteEngine on
RewriteRule ^/.well-known/webfinger?resource=acct:([^@]+)@s3lph.me$ /.well-known/_webfinger/$1 [L]

Der webfinger-Endpunkt verlinkt nun weiter auf die tatsächlichen User-Ressourcen:

{
  "subject": "acct:s3lph@s3lph.me",
  "aliases": [
    "https://s3lph.me/author/s3lph.html",
    "https://s3lph.me/activitypub/users/s3lph"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://s3lph.me/author/s3lph.html"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://s3lph.me/activitypub/users/s3lph"
    }
  ]
}

In diesem Fall wird der Autor, in diesem Fall @s3lph@s3lph.me auf zwei Alias-URLs aufgelöst: Der Autoren-Feed im Blog, sowie die Person ActivityPub-Ressource.

ActivityPub: Personen und Artikel

Jetzt, wo wir einen Username wie @s3lph@s3lph.me zu einer ActivityPub-URL wie https://s3lph.me/activitypub/users/s3lph auflösen können, können wir die Personen-Ressource unter dieser URL genauer anschauen:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],
  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog.",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},
  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],
  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",
  "discoverable": true,
  "manuallyApprovesFollowers": true,
  "published": "2020-02-05T01:36:00+01:00",
  "updated": "2022-11-12T14:05:23Z",
  "endpoints": {
    "sharedInbox": "https://s3lph.me/activitypub/collections/inbox/s3lph"
  },
}

Diese Ressource ist schon ein gutes Stück grösser, daher schauen wir das am besten Stück für Stück an. Zuerst wird das Schema des JSON-Dokuments beschrieben:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],

Als nächstes werden die grundlegenden Informationen über die Person beschrieben. Diese werden von ActivityPub-Clients benutzt, um die User-Seite darzustellen:

  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog...",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},

Zusätzlich können noch einige weitere Metadaten angegeben werden:

  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],

Attachments vom Typ "PropertyValue" werden z.B. von den meisten ActivityPub-Clients als Tabelle im Profil des Users dargestellt. Unter tags werden z.B. Hashtags oder Erwähnungen von anderen Usern im Profil des Users aufgelistet, damit diese von anderen Servern im Fediverse indexiert werden.

Mit movedTo und alsoKnownAs wird angegeben, dass der Account umgezogen ist, und andere User dem neuen Profil folgen sollen.

Damit kommt ein Profil zustande, das mit Fediverse-Clients aufgerufen werden kann. So zum Beispiel in der Mastodon-App Tusky:

Abbildung 2: Das Profil von @s3lph@s3lph.me, dargestellt in der Mastodon-App Tusky.

Schlussendlich müssen noch die URLs zu den verknüpften ActivityPub-Ressourcen angegeben werden:

  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",

Die Bedeutung von Inbox und Outbox wurden vorhin schon kurz erwähnt. Da in dieser Implementation keine Inboxen verarbeitet werden, steckt hinter der Inbox einfach nur eine leere "Collection" (Liste aus ActivityPub-Ressourcen). Das gleiche gilt für die "following"- und "followers"-Collections:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "OrderedCollection",
  "id": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "totalItems": 0,
  "orderedItems": []
}

Die Outbox ist ebenfalls eine Collection, die die von diesem User veröffentlichten Artikel enthält. Ein solcher (dieser) Artikel sieht z.B. so aus:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "Article",
  "id": "https://s3lph.me/activitypub/posts/activitypub-static-site",
  "published": "2022-11-17T02:00:00+01:00",
  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],
  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,
  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]
}

Der Anfang eines Artikels sieht mehr oder weniger gleich aus wie bei einer Person, daher wiederhole ich das hier nicht nochmal.

Als nächstes wird die Beziehung zu anderen Ressourcen angegeben, z.B. wer den Artikel verfasst hat, und an wen der Artikel adressiert ist. Die spezielle URL https://www.w3.org/ns/activitystreams#Public beschreibt, dass der Artikel öffentlich ist und z.B. in globalen Timelines aufgeführt werden soll:

  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],

Titel und Inhalt des Artikels können mehrsprachig angegeben werden. Allerdings werden contentMap und nameMap nur von wenigen ActivityPub-Servern implementiert. Die meisten Server zeigen einfach immer den unübersetzten Standard-Inhalt an:

  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,

Schlussendlich werden - genau wie bei Personen auch - weitere Daten wie Tags oder Erwähnungen anderer Personen in maschinenlesbar aufgeführt:

  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]

Damit haben wir Autoren und deren Artikel vollständig als ActivityPub-Ressourcen abgebildet. Damit ist auch alles implementiert, was sich sinnvoll als rein statische Seite implementieren lässt.

Der Code, um diese ActivityPub-Ressourcen zu erzeugen, ist als Pelican-Plugin verfügbar. Bevor irgendjemand das Plugin in das eigene Pelican einbaut, würde ich aber dazu raten, diesen Artikel bis zum Ende zu lesen.

Kompatibilität mit Fediverse-Diensten

Zum Testen habe drei verschiedenen Fediverse-Testinstanzen aufgesetzt, um mit meinem Blog zu interagieren: Mastodon, Pleroma und Misskey.

Mit allen drei Diensten kann das Profil @s3lph@s3lph.me aufgerufen werden. Auch die Anzahl an Blogartikeln wird korrekt dargestellt, allerdings werden die Artikel selbst nicht angezeigt. Wie sich herausstellt, werden Artikel von anderen Instanzen üblicherweise nicht automatisch geladen.

Die Artikel würden nur dann angezeigt, wenn sie von ihrer Ursprungsinstanz in die Inbox der Zielinstanz gePOSTed werden. Alternativ war es aber bei allen drei Testinstanzen möglich, die URL des Artikels in der Suche einzugeben und so aufzurufen. Danach wird der jeweilige Artikel auch in der Timeline des Autors angezeigt.

Leider ist dies eigentlich auch schon alles, dass mit einer rein statischen Implementation wirklich funktioniert. Insbesondere die folgenden - doch recht zentralen - Funktionen sind nicht verfügbar:

Folgen: Wenn Alice im Fediverse dem Account vom Bob folgen möchte, sendet sie eine Follow Request in die Inbox von Bob. Bob (resp. Bob's Instanz) muss diese Anfrage allerdings erst bestätigen.

Antworten: Wenn Alice auf eine Nachricht von Bob antwortet, ist diese Antwort zunächst nur auf der Instanz von Alice sichtbar. Die Antwort wird auch in die Inbox von Bob zugestellt, und Bobs Instanz wäre dafür zuständig, die Antwort an alle anderen involvierten Instanzen weiterzuleiten. Dieser Schritt bleibt im statisch generierten Fall aber aus.

Löschen: Wenn ein Artikel im Cache einer anderen Instanz ist, wird er dort üblicherweise behalten, bis er von der Ursprungsinstanz explizit gelöscht wird. Hierzu muss eine signierte Löschanfrage an die Inbox der anderen Instanz gesendet werden.

Aktualisieren: Für Änderungen an Artikeln gelten die gleichen Einschränkungen wie für das Löschen von Artikeln. So wird dieses Listenelement niemals auf Instanzen auftauchen, die bereits eine ältere Version des Artikels in ihrem Cache haben.

Was hingegen funktioniert sind Likes und Boosts, allerdings werden diese nicht in den Zählern unter den Artikeln reflektiert.

Zudem stellt z.B. Mastodon nur einen Link zum Artikel dar, da die hier verwendeten Article-Objekte nicht vollständig unterstützt werden; für Kurznachrichten wird der Objecttyp Note verwendet. Sowohl Pleroma als auch Misskey können den Artikel aber vollständig nativ darstelle.

Fazit

Die Frage «Wie viel ActivityPub kann ein Static Site Generator?» lässt sich zusammenfassend wohl am besten beantworten mit «viel, aber nicht genug, um praktische Relevanz zu haben.» Auch für diesen Blog wird eine rein statische ActivityPub-Implementation wahrscheinlich keine wirkliche Relevanz haben, aber die Artikel werden voraussichtlich weiterhin in dieser eingeschränkten Form im Fediverse verfügbar sein.

PS: Falls du diesen Artikel im Fediverse liest, und eine Antwort schreiben willst, adressiere deine Antwort bitte (zusätzlich) an @s3lph@chaos.social, sonst bekomme ich davon nichts mit.

# contentMap.en
There already are multiple blogging solutions which are part of the Fediverse. Among them are dedicated Fediverse blogs such as Plume or WriteFreely, but there also are plugins which retrofit existing CMS, e.g. for Wordpress or Drupal.

ActivityPub, the network protocol behind the Fediverse can only be fully implemented by means of an active server component: Among other things, incoming messages delivered to inboxes have to be processed. Sometimes they need to be forwarded, and outgoing messages need to be signed.

Figure 1: The basic mechanisms behind ActivityPub: Users publish resources in their outbox, and retrieve received resources from their inbox.

Nevertheless I wanted to figure out, which parts of the ActivityPub protocol can be implemented in a purely static website, and how well other servers in the Fediverse interact with it. My goal was to attach this blog to the Fediverse. The blog is generated using the static site generator software Pelican.

Metadata Endpoints

One prerequisite for implementing ActivityPub are multiple static metadata endpoints, with which a server signals whether it supports ActivityPub, and under which HTTP endpoints the various ActivityPub resources are reachable:

/.well-known/nodeinfo simply links to the "real" nodeinfo endpoint:

{
  "links": [
    {
      "href": "https://s3lph.me/activitypub/nodeinfo",
      "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0"
    }
  ]
}

Behind the linked path (which can be chosen freely), the global metadata of this ActivityPub instance is published:

{
  "version": "2.0",
  "software": {
    "name": "pelican-activitypub",
    "version": "0.1"
  },
  "protocols": [
    "activitypub"
  ],
  "services": {
    "inbound": [],
    "outbound": [
      "atom1.0",
      "rss2.0"
    ]
  },
  "openRegistrations": false,
  "usage": {
    "users": {
      "total": 1
    },
    "localPosts": 27
  },
  "metadata": {
    "nodeName": "s3lph made"
  }
}

This JSON document describes the server, however we still need to discover the individual users of this instance. This is where the webfinger endpoint comes in. Usually it is found at /.well-known/webfinger, but some pieces of software insist on first resolving this path using the /.well-known/host-meta endpoint:

<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  <Link rel="lrdd" template="https://s3lph.me/.well-known/webfinger?resource={uri}" type="application/xrd+xml" />
</XRD>

Now we already stumbled over the fist obstacle preventing a purely static implementation: The name of the user to be resolved is passed as an URL parameter, which needs to be handled by a HTTP server.

There are two possible workarounds:

If there only is a single user on the server, the parameter can be ignored. Instead, always the same static response is returned, no matter the URL parameter.

If there are multiple users, we can generate a static webfinger endpoint per user. This requires the webserver to be configured to redirect the regular webfinger endpoint to these user specific endpoints.

For example, using the Apache webserver, this can be achieved like this:

RewriteEngine on
RewriteRule ^/.well-known/webfinger?resource=acct:([^@]+)@s3lph.me$ /.well-known/_webfinger/$1 [L]

The webfinger endpoint links to the actual user resources:

{
  "subject": "acct:s3lph@s3lph.me",
  "aliases": [
    "https://s3lph.me/author/s3lph.html",
    "https://s3lph.me/activitypub/users/s3lph"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://s3lph.me/author/s3lph.html"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://s3lph.me/activitypub/users/s3lph"
    }
  ]
}

In this case, the author @s3lph@s3lph.me is resolved to two alias URLs: The author's feed in the blog, as well as the ActivityPub Person resource.

ActivityPub: Persons and Articles

Now that we can resolve usernames such as @s3lph@s3lph.me to ActivityPub URLs such as https://s3lph.me/activitypub/users/s3lph, we can take a closer look at the Person resource behind this URL:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],
  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog.",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},
  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],
  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",
  "discoverable": true,
  "manuallyApprovesFollowers": true,
  "published": "2020-02-05T01:36:00+01:00",
  "updated": "2022-11-12T14:05:23Z",
  "endpoints": {
    "sharedInbox": "https://s3lph.me/activitypub/collections/inbox/s3lph"
  },
}

This resource is a bit bigger than before, so let's look at it piece by piece. At first the schema of this JSON document is described:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "schema": "http://schema.org#",
      "toot": "http://joinmastodon.org/ns#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "discoverable": "toot:discoverable"
    }
  ],

Next the basic information about the person are described. These are used by ActivityPub-Clients to display the user's profile page:

  "type": "Person",
  "id": "https://s3lph.me/activitypub/users/s3lph",
  "preferredUsername": "s3lph",
  "url": "https://s3lph.me/author/s3lph.html",
  "name": "s3lph made",
  "summary": "This is an EXPERIMENTAL implementation for a read-only ActivityPub feed of my blog...",
  "icon": {
    "type": "Image",
    "mediaType": "image/png",
    "url": "https://s3lph.me/favicon.ico"
  },
  "image": {},

Some additional metadata can be provided:

  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "Web",
      "value": "<a href=\"https://s3lph.me\">s3lph.me</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Mastodon",
      "value": "<a rel=\"me\" href=\"https://chaos.social/@s3lph\">@s3lph@chaos.social</a>"
    },
    {
      "type": "PropertyValue",
      "name": "Matrix",
      "value": "<a rel=\"me\" href=\"https://mto.kabelsalat.ch/#/@s3lph:kabelsalat.ch\">@s3lph:kabelsalat.ch</a>"
    }
  ],
  "movedTo": "https://chaos.social/users/s3lph",
  "alsoKnownAs": [
    "https://chaos.social/users/s3lph"
  ],

"PropertyValue" attachments are rendered as tables on the user's profile page by most ActivityPub clients. tags list attributes such as hashtags and mentions of other users within the profile's description, so that they can be easily indexed by other servers in the Fediverse.

movedTo and alsoKnownAs are used to indicate that the account has moved, and the new account should be followed instead.

With all of this, we get a user profile that can be viewed on Fediverse client applications, such as the Mastodon app "Tusky":

Figure 2: The profile of @s3lph@s3lph.me, as shown in the Mastodon app Tusky

Finally, the URLs to linked ActivityPub resources have to be provided:

  "inbox": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "outbox": "https://s3lph.me/activitypub/collections/outbox/s3lph",
  "following": "https://s3lph.me/activitypub/collections/following/s3lph",
  "followers": "https://s3lph.me/activitypub/collections/followers/s3lph",

I've already mentioned the relevance of the inbox and outbox before. Since this implementation does not process inboxes, there only is an empty colllection (list of ActivityPub resources) behind the inbox URL. The same goes for the "following" and "followers" collections:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "OrderedCollection",
  "id": "https://s3lph.me/activitypub/collections/inbox/s3lph",
  "totalItems": 0,
  "orderedItems": []
}

The outbox is a collection as well, containing the articles published by this user. One of these articles (actually this one) looks like this:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams"
  ],
  "type": "Article",
  "id": "https://s3lph.me/activitypub/posts/activitypub-static-site",
  "published": "2022-11-17T02:00:00+01:00",
  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],
  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,
  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]
}

The beginning of an article is more or less equal to that of a person, so I won't be repeating it here.

Next the relationships to other resources are described, e.g. who wrote the article, and to whom it is addressed. The special URL https://www.w3.org/ns/activitystreams#Public describes that the article is public, and should e.g. be listed in global timelines:

  "inReplyTo": null,
  "url": "https://s3lph.me/activitypub-static-site-de.html",
  "attributedTo": "https://s3lph.me/activitypub/users/s3lph",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://s3lph.me/activitypub/collections/followers/s3lph",
    "https://chaos.social/users/s3lph"
  ],

Title and content of the article can be multilingual. However, the contentMap und nameMap attributes are only supported by a few ActivityPub servers or clients. Most others simply always show the untranslated default content:

  "name": "How much ActivityPub can a Static Site Generator implement?",
  "nameMap": {
    "en": "How much ActivityPub can a Static Site Generator implement?",
    "de": "Wie viel ActivityPub kann ein Static Site Generator?"
  },
  "content": "<p>There already are multiple blogging solutions...",
  "contentMap": {
    "en": "<p>There already are multiple blogging solutions...",
    "de": "<p>Es gibt bereits verschiedene Blog-Lösungen...",
  },
  "summary": null,

Finally, same as with persons, additional data like tags and mentions of other users are listed in a machine readable form:

  "attachment": [],
  "tag": [
    {
      "type": "Hashtag",
      "name": "#activitypub",
      "href": "https://s3lph.me/activitypub/tags/activitypub"
    },
    {
      "type": "Hashtag",
      "name": "#pelican",
      "href": "https://s3lph.me/activitypub/tags/pelican"
    },
    {
      "type": "Mention",
      "href": "https://chaos.social/users/s3lph",
      "name": "@s3lph@chaos.social"
    }
  ]

Sow now we have modelled authors and their articles as ActivityPub resources. This is also everything which can be reasonably implemented as a purely static site.

The code for generating these ActivityPub resources is available as a Pelican plugin. However, before you go ahead and install this on your own Pelican blog, I'd advise you to finish reading this article.

Compatibility to Fediverse Services

For testing my implementation, I set up three different Fediverse instances to interact with my blog: Mastodon, Pleroma and Misskey.

All three services are able to retrieve and show the profile @s3lph@s3lph.me, and the number of blog articles is shown. However, the articles themselves are not shown. As it turns out, articles from other instances are usually not retrieved automatically.

The articles would only be shown if they were POSTed from their origin instance to the target instance. Alternatively, all three test instances were able to show the articles by pasting the article's URL into the search bar. Afterwards these articles were also shown in the timeline of their author.

Unfortunately, this is almost everything that can be achieved with a purely static implementation. Especially the following important functions are not available:

Following: If Alice wants to follow the Fediverse account of Bob, she has to send a follow request to Bob. Bob (or his instance) has to confirm this request before it becomes effective.

Replies: If Alice wants to reply to a message of Bob, her response is only visible on Alice's instance at first. The response will also be posted to the inbox of Bob, and it would be in his instance's responsibility to forward the reply to all other involved instances. This crucial step is not possible with a static ActivityPub endpoint.

Deletion: If an article ha been cached by another instance, it is usually kept in the cache until it's explicitly deleted by its origin instance. For this, a cryptographically signed deletion request would have to be submitted to the instance's inbox.

Updates: The same limitations that apply to deletions apply to updates as well. In fact, this list item will never show up on instances that already have an earlier version of this article in their cache.

What does work are likes and boosts, tough they do not increment the counters under an article.

Additionally, it turns out that e.g. Mastodon only shows a link preview to the article, since the Article object type is not fully supported; the Note type is used for short messages. However, both Pleroma and Misskey show the full article natively.

Conclusion

The question "How much ActivityPub can a Static Site Generator implement?" can thus be best answered as "quite a lot, but not enough to be relevant in practice." Even for this blog, the purely static ActivityPub implementation will most likely not have any relevance, however the articles will presumably keep being published on the Fediverse in this limited form.

PS: If you're reading this article on the Fediverse and want to write a reply, please (additionally) address it to @s3lph@chaos.social, otherwise I won't see it.