Tag: build

No database. One JSON file per post.

Here are three lines of Markdown, and here is what the file on disk looks like after you save them.

What you type:

Posts are **files**. The build is a loop over a folder.

There is no step three.

What lands on disk, one JSON file per post:

{
  "slug": "no-step-three",
  "title": "No step three",
  "date": "2026-07-31T09:00:00+02:00",
  "state": "published",
  "tags": ["content"],
  "content": [
    {
      "type": "text",
      "text": "Posts are files. The build is a loop over a folder.",
      "formatting": [
        { "type": "bold", "start": 10, "end": 15 }
      ]
    },
    {
      "type": "text",
      "text": "There is no step three."
    }
  ],
  "source": { "platform": "manual" }
}

(Trimmed for the post — a real file carries a couple more housekeeping fields — but this is the real shape.)

Look at where the bold went

The text is plain text. The bold is a pair of offsets pointing into it — not <strong> tags baked into a string, not a Markdown blob waiting to be parsed again. That one decision quietly pays for half the engine:

  • Search indexes the text as-is. No stripping tags, no "why does searching for strong match every post".
  • The build never parses Markdown. Parsing happens once, at save time; the build just renders blocks it already understands.
  • Importers from seven platforms all target this same schema — which is why a new import source is an adapter with three methods, not a fork of the parser.

Round trips, with a seatbelt

edit converts the JSON back to Markdown, you edit, it parses again on save. And when a post contains something Markdown can't express — an embed imported from Bluesky, a link card — the editor warns you before saving would flatten it, and asks. Nothing gets silently thrown away in the round trip.

The boring finale

One post is one file, and I mean that as infrastructure, not poetry:

  • backup is tar
  • history is git
  • migrating away from ./blog.sh is a for loop over a folder of self-describing JSON

And one consequence worth saying out loud: the deployed site is just a build artifact. The working copy — content, media, config — lives on your machine, entirely under your control, no matter where the web part runs or what happens to it. Host disappears, server catches fire, provider triples its prices? Nothing of value was there. You point the deploy at a different backend and the site exists again, byte for byte. The server was never the blog. Your folder is.

An engine should be easiest to leave the same way it was easiest to join. This is that, in one file per post.

Read more

No gems (one asterisk)

./blog.sh needs no gems. Here's the one asterisk on that sentence, because a claim without its exception isn't worth much.

The claim first: zero gems, zero npm packages, no Bundler, no lockfile, no node_modules folder quietly gaining weight in the dark. Ruby 2.7 or newer and bash. Clone it and it runs.

The asterisk, up front

Two optional sidebar widgets — Pixelfed and generic RSS — parse XML with rexml. That's a default gem: it ships bundled with every normal Ruby installation, so for most people the claim holds as written. But some Linux distributions split Ruby into packages and put the default gems in a separate one.

So: if you enable those two widgets on a distro-packaged Ruby and the build complains about rexml, you're the asterisk. Two ways out — install your distro's ruby-rexml-ish package, or gem install rexml. Either way it's one package, once, and only if you use those widgets at all.

That's the entire dependency story. I'd rather write it out than round it down to zero and wait for someone's build to disagree.

What a real zero buys you

No network dependencies at build time means the entire site builds offline. On a plane, on a train, on a server that firewalled itself into a corner — ruby build/build_blog.rb neither knows nor cares.

And the output is as boring as the input. Measured on this site, today, fourteen posts:

Measured on this siteValue
Full build, cold1.9 s
Full build, warm (only changed files written)0.9 s
Homepage HTML54 kB
All CSS24 kB
All JavaScript23 kB — 555 lines across 8 files
Requests to third-party domains1*

That JavaScript is the complete inventory: theme toggle, lightbox, client-side search, the comments loader, sidebar widgets, mobile nav, scroll-to-top, and a shared utility file. No framework is hiding under any of them.

And yes, a second asterisk snuck into the table, same policy as the first: the one third-party request is my own self-hosted Umami analytics, on another domain of mine, added through the config. The engine itself ships exactly zero — no fonts CDN, no tracker, no embed phoning home. Turn the analytics line off in site.yml and the number is a structural 0.

Why bother

Because every dependency is a small standing appointment with the future. Some Tuesday it needs updating, audits, replacing, or it deprecates the one function you used. A stdlib-only tool skips those appointments — the price is writing a bit more code yourself, and for a blog engine that price turned out to be surprisingly low.

A zero with its exception written down is more useful than a zero without one. If you trust the asterisk, you can trust the rest of the table.

Read more