5 min read

Indieweb theme for Ghost

Recently I found myself thinking about my experiment in making my Ghost CMS compatible with the IndieWeb.

I adapted my whole system so I could tap into the benefits of the small web, and then the idea came to me: why not create a theme that is 100% compatible with the IndieWeb?

And here I am.

Download Here.

I already had a theme. pablawn was my home: a tiny blog in a retro directory style, a dense list of posts where each text is a single line — date on the left, title in the middle, reading time on the right, and a dotted line stitching the two together like the table of contents of an old book. And underneath that lean aesthetic, I had taught Ghost how to speak IndieWeb: an invisible h-card proving who I am, posts marked up as h-entry, and webmentions arriving from across the web.

It worked beautifully — for me. The problem is that it worked only for me. My name was in the HTML. My photo, hosted on my own server, was in the HTML. My eleven rel="me" profiles, my webmention endpoint, my emails, even the array of “owned identities” that filters out the echo of my own publications: everything was hardcoded into the templates. It was a theme for one person only.

The idea driving us was simple to state and stubborn to execute: what if that same theme could be freely distributed, and anyone could configure their own IndieWeb identity directly inside the Ghost admin — without needing to know how to edit Handlebars, without touching CSS, without touching code? The free theme would be called indawn.

Ghost allows a theme to declare panel options — but with strict constraints. There are only a few types (select, boolean, color, image, text); text fields are single-line only; there is no list or repeatable field; and there is a practical ceiling of around twenty options in total.

Then came the first punch of reality: pablawn had already spent fourteen of those twenty options on font choices, navigation layout, color scheme, and homepage sections. That left six slots. Six. To fit an entire identity plus an arbitrary list of social networks.

And there was a second punch, subtler and more technical. rel="me" — the thread that stitches “this domain and these accounts are the same person” together — needs to be in the HTML delivered by the server. IndieWeb validators fetch the raw HTML; they do not run JavaScript. That killed the clever route every programmer tries first: store all the links in a single text field and split them with JS at runtime. No dice. Each rel="me" link has to come from a named field, rendered by Handlebars on the server. And Handlebars has no way to split a string. In other words: one social network = one panel field. No shortcut.

Six slots. One entire identity. The math simply did not work — until we stopped trying to fight Ghost and started using it in our favor.

The piece that unlocked everything was realizing that Ghost already stores a large part of the h-card — just in the native fields everyone fills out without thinking:

  • the name (p-name) is the site title;
  • the photo (u-photo) is the site icon;
  • the canonical URL (u-url) is the site URL;
  • the bio (p-note) is the site description.

None of that costs a slot. The central identity of the semantic business card started assembling itself from things the user configures in Settings → General without even knowing they are feeding microformats.

And there was an almost indecent bonus: Ghost natively has two Social accounts fields — X/Twitter and Facebook. Two rel="me" links for free, without spending any of the budget. Along the way, we replaced the old {{twitter_url}} helpers with the modern {{social_url type="..."}} helpers — gscan complained, and a distributable theme cannot be born with a warning.

The six fields that remained

With the identity coming for free, the six remaining slots were available for what truly needed its own field. We spent them like this:

  • webmention_endpoint — where the person pastes their own webmention.io address;
  • social_github, social_mastodon, social_bluesky, social_linkedin, social_youtube — one complete URL per network.

Five named networks, plus the two native ones, make seven in total. The choice was not random: GitHub, Mastodon, and Bluesky are the core of the open web, where rel="me" can actually verify back; LinkedIn covers the professional side; YouTube covers people who create video. Instagram, Threads, and TikTok were left out on purpose — they are walled gardens where rel="me" almost never closes the loop, so they would be decoration, not verification.

Two design decisions hold this arrangement together. The first is the rule “empty means off”: no field has a separate on/off switch — if you do not fill it in, it simply does not appear. That saves precious slots and makes the panel self-explanatory. The second is that every network is a complete URL, not a “username” — because on Mastodon the instance varies, and asking only for the handle would break half the cases.

Each filled field feeds two places from a single source: the hidden h-card at the top (what robots read) and the discreet footer icons (what humans see). One truth, two appearances.

What we chose not to do

A large part of the work of a free theme is having the discipline to cut. We consciously left out: the h-card extras (job title, organization, region, country) — too niche; the self-mention filter, which we reset and left commented for anyone who might want it one day; the exotic networks that did not fit the rule; and the sending of webmentions, which was never the job of a theme — it is an external service that watches the RSS feed and notifies other sites for you. The theme only needs to deliver an honest h-entry, and it already does that.

We also cleaned up the personal residue that was not IndieWeb: an old JavaScript trick that swapped the sample texts of the members portal, the hardcoded author photo in posts, the three fixed footer links. Everything that said “Pablo” in the HTML came out — only the author signature in package.json and the README remained, which is exactly where it belongs.

How it ended up inside

Once the design was clear, the fork became mechanical: copy the theme, rewrite package.json (name indawn, version 0.0.1), replace the h-card block with dynamic fields, condition the <link rel="webmention"> on the existence of the endpoint, make the footer dynamic, reset the personal array in JavaScript, and rewrite the documentation.

The verdict came from gscan, Ghost’s official validator: compatible with Ghost 6.x, zero warnings. One final search pass confirmed that neither the sources nor the rebuilt files carried any trace of personal identity. Then all that remained was packaging it: indawn.zip, ready to be uploaded to any Ghost site in the world.

The most useful lesson was not technical — it was attitudinal. For quite a while, we fought Ghost, trying to force into it a list of links it was never meant to have. Progress only came when we stopped wanting Ghost to be something else and asked: what does it already know how to do that I am ignoring? The answer — “it already stores your name, your photo, your URL, your bio, and two networks” — solved half the problem before we wrote the first line of configuration.

The other lesson is about the ceiling. indawn is born exactly at twenty out of twenty options, right up against the limit. There is no spare room, and that is honest: I left it recorded, both in the guide and in my head, that gaining a sixth network one day will mean giving something else up. Constraint is not a failure of design; often, it is the design itself.

from the front, instead of coming with it already written on the wall.