< the skull

INDIEWEB THEME FOR GHOST
========================
2026-06-22

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 [1].

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.

Links:
  [1] https://github.com/runawaydevil/idawn-theme


< the skull