< the skull

TEACHING GHOST TO SPEAK INDIEWEB
================================
2026-06-16

Where this started
------------------

I like Ghost. It's lightweight, open source, writes well, and stays out of the
way. But Ghost was born as a publishing and newsletter platform — not as a
citizen of the IndieWeb. It doesn't publish an `h-card` for my identity, doesn't
mark up my posts with `h-entry`, and has no idea how to send or receive
webmentions. To a machine, my site was just nice-looking HTML: humans could read
it, software understood almost nothing.

The idea behind this experiment was simple: keep Ghost exactly as it is — no
plugin, no fork, no extra server humming away beside it — and still get my site
talking to the open web. Everything you see here lives in the theme (Handlebars
plus a little CSS and JavaScript) and in one free hosted service. Not a single
line of server code.

And I gave myself one non-negotiable rule: don't lose 1% of the look. The blog
is a lean little list, almost a retro directory. Anything I added had to be
either invisible or as understated as everything else.

Identity first: the invisible h-card
------------------------------------

Everything on the IndieWeb starts with proving who you are. You do that with
microformats — a handful of class names you bolt onto your existing HTML so
software can pick out your name, photo, URL, email, and profiles. The `h-card`
is the semantic business card.

In my case, I didn't want a card showing up on screen; I wanted only machines to
read it. The solution was a hidden `h-card` block at the top of every page,
using the `hidden` attribute. Readers see nothing; IndieWeb parsers read the
HTML just fine, because they ignore CSS.

Alongside it, I scattered `rel="me"` links to my profiles (GitHub, Codeberg,
SourceHut, Mastodon, and so on). That's what cements "this domain and these
accounts are the same person." The detail almost everyone forgets: `rel="me"`
only closes the loop if the profile links back to your domain too. Without the
return link, the verification never happens.

Posts machines can read: h-entry
--------------------------------

Identity sorted, the posts were next. `h-entry` is the microformat that marks
each post as an "entry" — with a title (`p-name`), content (`e-content`),
publish date (`dt-published`), canonical URL (`u-url`), author, and categories.

The beauty of it is that, again, it's just class names added to elements that
were already there. The `<article>` got `h-entry`, the `<h1>` got `p-name`, the
body got `e-content`. The post's tags became `p-category`. The author sits
embedded as a hidden `p-author h-card`, reusing the same photo from my card.

The one technical thing that needed care was the date. `dt-published` wants an
ISO 8601 format with a timezone, and the theme only showed `day/month`. The fix
was to split the two apart: the `datetime` attribute holds the full
machine-readable date, while the text on screen stays exactly as it was. The
reader sees "05 Jun"; the parser sees `2026-06-05T23:51:21-03:00`. Zero visual
change.

I marked up both the individual posts and the home listing — because IndieWeb
feed readers consume both.

Receiving conversations: webmentions with no server
---------------------------------------------------

This is where it gets interesting. Webmention is the open web's way of doing
what likes and comments do inside social networks — except between independent
sites. When someone replies to or mentions one of my posts from their own site,
I get notified, and it can show up as a comment here.

The catch: receiving a webmention, in theory, needs a server to validate and
store each notification. I didn't want to run that. The way out was
webmention.io, a free hosted service that does all the heavy lifting. On my end,
all it took was announcing its address with a single invisible line in the
`<head>`:

  <link rel="webmention" href="https://webmention.io/yourdomain.com/webmention">

That's it. From there on, any mention of my site gets received, verified, and
archived by them. One elegant detail: logging into webmention.io happens through
my own domain, using exactly the `rel="me"` links I'd already added. The pieces
clicked together.

(I made a deliberate choice not to enable legacy pingbacks — they pull in way
too much spam.)

Displaying it my way
--------------------

Receiving is half the story; I wanted to show these conversations — but in my
own style, not in some generic third-party widget fighting the blog's look.

I went with a small bit of my own JavaScript. It reads webmention.io's public
API by the post's URL (no key needed), splits reactions (likes, reposts) from
replies, and builds the markup by hand. Reactions become a compact row of
avatars; replies become a clean list with the same monospaced date and the same
dotted line I use on the home page. The commenter's name lights up in the brand
color on hover.

And the part that mattered most for the aesthetic: if there are no mentions, the
section just doesn't exist. No "0 comments," no empty box. Silence, the way it
should be on a minimalist blog.

This is where I closed a funny little loop: I'd turned off Ghost's native
comments because I didn't want "discussion" locked inside a membership platform.
Webmentions are the open version of that — the conversation happens across the
whole web and only echoes here.

What still lives outside
------------------------

Technical honesty: one half of webmention — sending — still isn't inside the
theme, and it doesn't need to be. When I link to another IndieWeb site, I have
to fire off a notification to it. That's handled by an external service (like
Bridgy or webmention.app) that watches my RSS feed and sends them automatically.
The theme only needs solid `h-entry` markup — which is already there — for those
notifications to go out correctly formed.

So: the "passive" half (being an identity, having readable posts, receiving and
displaying conversations) all lives in the theme, invisible or understated. The
"active" half (reaching out to notify other sites) is a service wired to RSS, no
code.

In the end, none of this changes how the blog looks. A distracted visitor won't
notice a thing. But underneath, the site stopped being an island. It now
introduces itself, proves who it is, marks up its writing in a way any open tool
understands, and is ready to receive conversations from any corner of the web —
without leaning on Ghost for it, without leaning on any social network.

Ghost is still Ghost. I just taught it a second language — the one the open web
speaks — and did it without Ghost (or the reader) ever noticing.


< the skull