Série./blog.sh v1.0

./blog.sh — minimalistický blogovací systém

./blog.sh je blogovací systém, který se spouští z terminálu. Příspěvky jsou soubory, web je výsledek sestavení a komentáře žijí na Fediversu — v celé té větě nikde není databáze.

Tři věci ho odlišují od ostatních pěti set generátorů statických stránek:

  • Žádná databáze. Jeden příspěvek je jeden soubor JSON. Záloha je tar, historie je git, odchod je smyčka for.
  • Žádné závislosti. Standardní knihovna Ruby a bash. Žádné gemy, žádné npm, žádný lockfile. Stačí naklonovat a spustit.
  • Žádný systém komentářů. Každý příspěvek se ohlásí na Mastodonu nebo Bluesky; odpovědi na to oznámení jsou komentáře, které tvůj prohlížeč načítá z veřejného API.

Takhle to vypadá:

Číst dál

Built for one blog on purpose

This is not a general-purpose engine, and pretending otherwise would waste your afternoon.

./blog.sh was built around exactly one deployment: one author, one archive, a terminal, and comments on the Fediverse. That's usually the part a project hides on the "about" page. I'd rather lead with it, because it's also the explanation for everything opinionated about this tool. A general-purpose generator solves the general case and then makes you configure your way back to your specific one. This engine started at the specific case and never left.

So here's the honest table:

Číst dál

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:

Číst dál

Comments I don't host

The star, boost and reply counters sitting right under this post's title belong to a toot, not to this site. And the comments at the bottom of this page are that toot's replies. That's the whole system — here's how it works.

When a post is published, the engine announces it on Mastodon or Bluesky — one network per site, never both; the build refuses a config with two, because comments live in exactly one place. Replies to that announcement are the comments: your browser loads the thread straight from the network's public API, no login, no key, no middleman. The counters under the title are the same numbers, read from the same toot.

What falls out of that

No comment database — nothing to migrate, back up, or moderate at 2am. No spam filter, because the network already has one, and it's better funded than mine. The comment count is just the reply count. And moderation is your instance's moderation: block, mute and report all work exactly where the conversation actually happens.

Číst dál

Add your language. It's one file.

The one way to contribute to ./blog.sh without writing a line of Ruby is to translate it — and it's a single YAML file.

Everything the reader sees — navigation, dates, archive headings, the search box, the comments prompt — comes from a locale file. The whole guide lives here:

docs/localization.md — adding a language to ./blog.sh

Three things make it genuinely easy:

  • A language is data, not code. You copy en.yml, rename it, translate values. No templates touched, no Ruby read.
  • Fallback runs per key. A half-finished translation works from day one — anything you haven't translated yet simply shows up in English until you get to it.
  • Only en.yml must be complete. Yours can grow at whatever pace your weekends allow.

English, Czech and German are already in. Yours would make four.

Číst dál

What ./blog.sh doesn't do

A feature list tells you what a tool wants to be. This is the other list.

Features that aren't there — and aren't coming

  • Multiple authors — the engine is built around one person's workflow, and every multi-author feature taxes the single author with roles, permissions and attribution UI
  • Plugins — a plugin API is a promise to keep internals stable forever; a tool this small should be forked, not extended
  • A WYSIWYG editor — your $EDITOR took you years to configure; I'm not going to compete with it in a textarea
  • Its own comment system — storage, moderation, spam, GDPR, and a database, all to rebuild what the Fediverse already does in public
  • Themes as downloadable packages — seven colour keys and your own banner get you further than a theme marketplace, at zero marketplace

Unchecked checkboxes, as rendered by the engine's own task lists. They'll stay that way.

What the parser refuses

Číst dál

Why not X, and why Threads waits

Post read: $0.005 per resource. Post creation: $0.015 per request — $0.200 when it carries a URL. Free public read access: none.— X API pay-per-use pricing, docs.x.com, checked July 2026

Two networks are in. Two are not — and here's the whole reasoning, because "we decided against it" is not a reason.

./blog.sh announces every published post on Mastodon or Bluesky, and the replies to that announcement become the post's comments. For that to work, the engine needs two things from a network: a way to post, and a way for a browser to read a public thread. Cheap, ideally free, forever — because a personal blog is a decades-long project with a hobby budget.

X: no

Look at that price list again. Announcing a post with a link costs real money. Loading the reply thread — which happens every time anyone scrolls to the comments — costs real money, per read, forever. On a personal blog, that's a subscription to your own comment section, billed by your readers' curiosity.

There's no engineering answer to a pricing decision. So: no.

Threads: not yet

Číst dál

A deploy that refuses to nuke your site

Petr
I ran --prune after a broken build.
Pavel
So your site is empty.
Petr
No. The deploy refused.
Pavel
…I want that.

That's the whole feature: the deploy assumes a large change is a bug until you say otherwise.

How it works

Every deploy keeps a manifest — SHA-256, size and mtime for each uploaded file. The next deploy diffs against it and only touches what actually changed. Fast, boring, correct.

The interesting part is what happens before anything uploads. If the file count dropped by more than 20% since the last deploy, the whole thing stops. This is real output from this very site — I parked half the build's folders out of sight for a minute and asked for a deploy:

❌ Stopped: public.nosync/ has 28 files, but 62 were uploaded last time.
   That's a 55% drop -- looks like a broken build.
   Check the build output. If the drop is expected (you deleted a lot of posts), run again with --force.
Číst dál

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.

Číst dál

$ ./blog.sh add

From an empty terminal to a shareable draft, uncut.
Číst dál

Seven keys

There is not a single colour value in this site's stylesheet.

Every colour you're looking at right now — background, text, links, the navigation, the little tag pills — comes from seven keys in config/site.yml. The build compiles them into a colors.css file, and site.css just uses the variables. Change seven lines, rebuild, and the site is someone else entirely.

Here's a complete light-mode palette:

colors:
  light:
    bg: "#f5f8fa"
    text: "#444a5a"
    meta_text: "#657784"
    accent: "#1da1f2"
    nav_bg: "#eaf5fd"
    border: "#e1e8ed"
    pill_bg: "#d6ecfc"

And here's the same homepage in five palettes — nothing changed between these shots except seven lines of YAML and a rebuild:

Číst dál

Why I built this (38 seconds)

Why I built this — 38 seconds, one take, recorded on a phone

Thirty-eight seconds, recorded on a phone, no edit. The written version is below if you'd rather read.

There are hundreds of static site generators. I wrote another one anyway, and the reasons fit in less than a minute.

My posts lived on big platforms for fourteen years. Some of those platforms changed the rules. Some just disappeared. I spend my whole day in a terminal, so I wanted to write my blog there too. And a small personal blog does not need a database — files are enough.

So: posts are files, the site is a build, and comments live on the Fediverse. That's the whole idea.

Some posts are conversations

Petr
I need a dialogue in a blog post.
./blog.sh
Write it in a chat fence. Name, colon, line.
Petr
That's it?
./blog.sh
A line without a colon continues the one above it.
Petr
And Markdown inside?
./blog.sh
Works. Bold, links, code — the usual.
Petr
Every other engine wants a plugin for this.
./blog.sh
Every other engine wants a plugin for tables, too.

The source of that exchange is exactly what you just read — speaker's name, a colon, the line:

Petr: I need a dialogue in a blog post.
./blog.sh: Write it in a chat fence. Name, colon, line.
Petr: That's it?

Wrap those lines in a code fence tagged chat and you're done.

You just read the feature. That's the whole documentation.

Fourteen years, and every image came home

In 2012 I posted a photo to Instagram. The link still works. The photo doesn't.

If you kept a blog anywhere between 2010 and now, you know the drill. The platform got acquired, or pivoted, or "sunset some legacy infrastructure", and the images you embedded from their CDN quietly turned into grey rectangles. My personal archive goes back fourteen years across Tumblr, Twitter, Mastodon and a couple of other places — and a depressing number of its images now live at addresses that answer with nothing at all.

Here's what I mean, from my own archive. On February 22, 2012, I published a blog post called #53: Messengers of Spring? and embedded its photo straight from Instagram's CDN, the way everyone did back then. This is that link today:

Číst dál

./blog.sh 1.0

./blog.sh 1.0 is out.

A blog engine that's a folder and a shell script. Ruby stdlib and bash — no database, no gems, no admin. Comments live on the Fediverse. Eight importers, so your Tumblr and Twitter archives have somewhere to go. MIT licensed.

The site you're reading is the engine running itself.