< the skull

ONE POST, MANY WEBS
===================
2026-07-02

A while ago I wrote about the little bridge that keeps my two blogs in sync —
Ghost as the canonical home, prose.sh as a plain-text mirror, and even a way to
publish by sending an email. One post, two blogs, zero copy-paste.

That should have been the end of the story.

It wasn't, because I remembered I also had a Gemini capsule and a gopher hole
quietly rotting somewhere, each with content from two eras ago. And once you
have a bridge that reacts to "post published" and delivers things over SSH...
well. You know how this goes.

This is part two: how one webhook ended up feeding the entire small web, two
fediverse accounts, Bluesky, and every site I link to. Same rule as before — one
source of truth, one road out.

The small web speaks differently
--------------------------------

Gemini and gopher aren't just "small HTTP". They have opinions.

Gemtext, Gemini's format, is radical: no inline links, no inline formatting. A
link gets its own line, starting with `=>`. So you can't just dump Markdown
there — every `link [1]` in the middle of a sentence has to go somewhere.

The community convention is to hoist links out of the paragraph and list them
right below it. So the converter reads a paragraph, strips the link syntax,
remembers what it found, and flushes it after:

  markdown in:
    Last year I [switched ISPs](https://example.com/isp) and lost my public IP.
  
  gemtext out:
    Last year I switched ISPs and lost my public IP.
    => https://example.com/isp switched ISPs

Gopher is even older-school: plain text, and the unwritten rule says keep it
under ~67 columns because that's what classic clients render comfortably. Links
become numbered footnotes, like a scientific paper written on a napkin:

  gopher out:
    Last year I switched ISPs [1] and lost my public IP.
  
    Links:
      [1] https://example.com/isp

The whole converter is one pass over the Markdown, line by line, with two
renderers at the end. No AST, no dependency with 400 transitive packages.
Gemtext is simple enough that respecting it is easier than fighting it.

Ghost is the manifest
---------------------

Here's the design decision I'm most happy about, and it costs nothing: the
bridge keeps no database of posts. None.

Every time a post is published, the bridge asks Ghost's Admin API for the full
list of published posts and regenerates the indexes — the gemlog index, the
gopher menu, the Atom feed — from scratch:

  on publish:
    1. deliver the new post        (slug.gmi, slug.txt)
    2. fetch ALL published posts   (Ghost Admin API, paginated)
    3. rebuild every index from that list
    4. deliver the indexes

Stateless. Always correct. If an index ever looks wrong, restarting the service
fixes it, because there's nothing to corrupt — Ghost is the manifest.

And you get backfill for free: populating years of existing posts into a freshly
wiped capsule is the same code path, just looped. One flag, one restart, the
whole archive appears on gemini and gopher. Delete the flag, done.

Pagination, because archives grow
---------------------------------

A gemlog index with the whole archive on one page is fine at 10 posts and absurd
at 200. So indexes paginate — twenty per page, with newer/older navigation:

  gemlog/index.gmi        <- newest twenty
  gemlog/page-2.gmi       <- next twenty
  gemlog/page-3.gmi       ...
  
  phlog/gophermap         <- newest twenty
  phlog/page2/gophermap   <- next twenty

Since indexes are rebuilt from zero every time, stale pages get deleted before
writing. If posts disappear, pages disappear. No ghosts. (Well. Except the
canonical one.)

Gopher's dirty little secret
----------------------------

Here's a protocol fact that cost me an evening: gopher requests don't include
the hostname. No Host header, nothing. The client connects to an IP and sends a
selector, and that's it.

Which means: if two domains point at the same server, port 70 cannot tell them
apart. Name-based virtual hosting, the thing HTTP has had since the 90s, is
physically impossible in gopher.

I had an existing gopher site on another domain, same box. The fix is
beautifully dumb: the new hole owns the root, and the old site survives as
symlinks inside it, so every old deep link still resolves:

  /var/gopher/
    gophermap            <- new home (the menu controls what's visible)
    phlog/
    oldsite/     -> /home/oldsite/gopher     (branding link)
    archive/     -> /home/oldsite/gopher/archive   (compat: old selectors)

The menu shows what I want; the symlinks keep two decades of gopher etiquette
intact. Everyone's links still work, nobody notices anything changed. That's the
best kind of migration.

Homepages worth the trip
------------------------

If someone bothers to open a Gemini client or a gopher browser in 2026, the
least I can do is greet them properly. Both homepages got rebuilt from scratch:
a FIGlet banner, a piece of classic ASCII art (credited — small web, good
manners), and short English copy.

The static files are baked into the container image and pushed on startup, so
"redeploy the homepage" is just "rebuild the container". The blog sections
underneath them are regenerated by the bridge, so the homes never go stale.

Retiring the feed robot
-----------------------

For social announcements I used to run my posts through a hosted
RSS-to-everywhere service. It worked, but it's polling — the announcement shows
up whenever the feed gets read next — and it's one more account, one more
dashboard, one more thing.

The bridge already knows the instant a post goes live. So it now announces
natively:

  * Mastodon-compatible instances (including GoToSocial): one `POST
    /api/v1/statuses` with a Bearer token. The post's feature image gets
    downloaded and attached as media, with the title as alt text.
  * Bluesky: a session, an optional thumbnail blob, and a post whose link lives
    in an external embed card — so the URL doesn't eat into the 300-character
    budget, and you get the pretty preview.

The announcement text writes itself from what the post already has: an excerpt
if I wrote one, otherwise the first lines of the content. And instead of a
robotic "New post:", the opener rotates — deterministically, hashed from the
slug, so retries don't reroll it:

  openers = [ "Hot off the press:", "Fresh from the lab:",
              "Straight from the terminal:", "Just shipped:",
              "New transmission:" ]
  
  pick = hash(slug) % len(openers)

Same post, same opener, forever. Different posts, different flavor. Nobody
suspects a robot.

The double-post problem
-----------------------

Mirrors are forgiving: deliver the same file twice and you've overwritten it
with itself. Social is not. Every announcement is a new item in someone's
timeline, and re-announcing a typo fix is how you teach people to unfollow you.

Two layers fix it:

One — split the events. Ghost fires webhooks for "post published" and "published
post updated". Give each its own query string:

  .../hooks/ghost?event=published   -> mirrors + announce + webmentions
  .../hooks/ghost?event=updated     -> mirrors + webmentions. NEVER announce.

Editing a post updates every mirror and stays silent on social. Exactly what you
want.

Two — keep a ledger. A tiny JSON file in a persistent volume records which slugs
have been announced:

  ["my-first-post", "that-networking-rant", "one-post-many-webs"]

Webhook retries, container restarts, full backfills — none of them can announce
twice, because the slug is already in the book. The ledger only gets written
when at least one network accepted the announcement, so a total outage retries
cleanly next time.

Belt, suspenders. Timelines unspammed.

Webmentions: closing the loop
-----------------------------

The last piece is the most indieweb thing of all. When a post links to someone's
site, the polite move is to tell them — that's a webmention. On publish (and
update — receivers dedupe, it's in the spec), the bridge:

  * extracts every external link from the post,
  * asks each target "do you accept webmentions?" — an HTTP `Link` header or a
    `rel="webmention"` tag in the HTML,
  * and if yes, sends a tiny form: `source=my post, target=your page`.

  POST https://example.com/webmention-endpoint
  Content-Type: application/x-www-form-urlencoded
  
  source=https://myblog.example/one-post-many-webs/
  &target=https://example.com/the-page-i-linked

It runs in the background after the webhook responds, with timeouts, skipping my
own domains, capped at a sane number of targets. Most sites don't accept
webmentions. The ones that do get a knock on the door.

What the flow looks like now
----------------------------

   write in Ghost ── or send an email ──┐
                                         │  one webhook
                                         ▼
                                ┌─────────────────┐
                                │      bridge     │
                                └────────┬────────┘
          ┌──────────┬──────────┬────────┼─────────┬──────────┐
          ▼          ▼          ▼        ▼         ▼          ▼
       prose.sh   gemini://  gopher://  fediverse  Bluesky  webmentions
       (markdown) (gemtext)  (67 cols)  (2 accts)  (card)   (to whoever
                                                              I linked)

One post. Seven destinations. Zero extra effort at publish time — the effort was
spent once, building the pipes.

Lessons from part two
---------------------

  * Statelessness is a feature you choose. Making the CMS the single manifest
    deleted a whole class of bugs (and the database that would've hosted them).
  * Respect each medium's grammar. Gemtext without inline links, gopher at 67
    columns, Bluesky's 300 characters. Converting properly beats mirroring
    lazily.
  * Separate "changed" from "new". Mirrors want both events; announcements want
    exactly one. A query string was all it took.
  * Idempotency needs memory only where actions aren't reversible. Mirrors
    overwrite; social needs the ledger. Put state where it's mandatory, nowhere
    else.
  * Old protocols deserve real engineering. The gopher hostname problem is
    unsolvable in-protocol — but a menu plus symlinks made the migration
    invisible. Constraints breed elegant hacks.

The bridge started as "I'm tired of running scp twice." It's now a tiny
syndication hub that speaks five protocols, and I still just click Publish.

Boring magic, compounding.

And yes. It's still working.

See you around.

Links:
  [1] url


< the skull