Series./blog.sh v1.0

./blog.sh — a minimalist blogging system

./blog.sh is a blog engine you run from a terminal. Posts are files, the site is a build, and the comments live on the Fediverse — there is no database anywhere in that sentence.

Three things make it different from the other five hundred static site generators:

  • No database. One post is one JSON file. Backup is tar, history is git, leaving is a for loop.
  • No dependencies. Ruby stdlib and bash. No gems, no npm, no lockfile. Clone and run.
  • No comment system. Every post is announced on Mastodon or Bluesky; replies to that announcement are the comments, loaded by your browser from the public API.

This is what it looks like:

./blog.sh in light mode
Light mode
./blog.sh in dark mode
Dark mode

Try it in four lines:

git clone https://github.com/DanielSnor/blog.sh.git
cd blog.sh && ./setup.sh
./blog.sh add
./blog.sh preview

Everything else on this site is a chapter, not a feature list: authoring · markdown · deploy · comments · appearance · migration · i18n · philosophy. The reference manual is the README, and the whole Markdown dialect fits on one page — generated by the parser itself, so it can't lie.

MIT licensed. Source, issues and the occasional opinion: github.com/DanielSnor/blog.sh

Read more

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:

./blog.shHugoJekyllGhostWordPress
Databasenonenonenonerequiredrequired
DependenciesRuby stdlib*one binarygem ecosystemNode + MySQLPHP + MySQL
CommentsFediverse, built inbring your ownbring your ownnative + membersnative + plugins
Deploy6 backends, guardedbring your ownbring your ownit's a serverit's a server
Multiple authorsnoyesyesyesyes
Plugins / ecosystemno, on purposehugehugegoodunbeatable
Community sizepopulation: 1enormouslargelargea third of the web
10,000-post buildslowerseconds, wins easilyslown/an/a
Newsletternononoyes, nativevia plugins

(the asterisk is documented — one optional widget needs rexml on some distros)

Read the losing rows first; that's what they're there for. Hugo on ten thousand posts is dramatically faster, and if raw build speed on a huge archive is your constraint, take Hugo and my blessing. Ghost has a real newsletter business built in. WordPress has an ecosystem nobody catches up to, including a plugin for whatever you just thought of. A comparison table where the home team wins every row isn't a comparison — it's an ad with borders.

What the table can't show is the shape of the wins. "No database" isn't one row — it's why backup is tar, why hosting is any static server, why nothing needs a security patch on a Tuesday. "Comments built in" means the announcement toot, the reply thread and the unpublish cleanup are one feature, not three services on three bills.

Who this is for: one writer with a terminal, an archive they want to own outright, and an account on the Fediverse or Bluesky.

Who this isn't for: teams, clients, editorial workflows, anyone who needs a web admin, anyone whose readers must comment without leaving the page.

Built for exactly one blog — which is why it's this opinionated, and why it fits the blogs shaped like it unusually well.

Read more

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

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.

The comments also belong to the people who wrote them, in their own timeline, under their own name. A reply here is a real post by a real account — not a row in my database wearing a nickname.

What it costs

Honesty first: if you're not on the Fediverse or Bluesky, you don't get to comment. That's a real cost and I won't pretend otherwise. You can still read the thread — it's public — and the bar for joining is an email address. For a personal blog, I'll take that trade over hosting a login system every day of the week.

Two details that earn their keep

unpublish deletes the announcement too. A toot pointing at a dead URL helps nobody, so taking a post down takes its thread anchor down with it.

Imported posts don't announce. The auto-announcement has a 24-hour window around "now" — so migrating a thousand posts from your old blog doesn't turn into a thousand-toot night for your followers. (You can still announce any post manually, whenever it deserves it.)

Try it on this very post

This is the part I can't fake, which is exactly why it's the demo. Reply to this post's toot — what's missing here, what would make you use this engine, or what convinced you not to — and your reply appears below, hosted by no one, moderated by your instance, owned by you.

Read more

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.

One design decision worth a paragraph, because it surprises people: there is no plural system, on purpose. Instead, every count phrase is written to fit any number — the way Czech, with its three plural forms, has been quietly working around other people's plural systems forever. It's the difference between shipping a translation tonight and reading a CLDR spec first.

The reward: a locale reviewed by a native speaker gets marked as shipped in the README, with your name on it. Community drafts are welcome too — they're labeled as such until a native speaker signs off.

One honest limit: no RTL yet. The templates don't mirror the layout, and pretending otherwise would waste an Arabic or Hebrew translator's evening. It's written down in the same doc.

Read more

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

The Markdown dialect turns things down too — each considered, each rejected, reasons attached:

  • Underscore italics (_like this_)
    • underscores live inside ordinary text: file_names, snake_case
    • the cost of supporting them lands on everyone who never uses them, as surprise italics mid-identifier
  • Code blocks indented with spaces
    • collides head-on with nested-list indentation
    • the backtick fence says what it means
  • Headings underlined with ===
    • a line of dashes already means a horizontal rule and the frontmatter delimiter
    • one more meaning for the same characters is one too many
  • Nested quotes (>>), reference links ([text][id]), footnotes
    • each adds parser states whose main output, in practice, is surprising rendering of innocent text

Notice the pattern: it's the same reason every time. The cost of a feature lands on the people who don't use it — as complexity, as surprise, as one more way for plain text to mean something you didn't intend. A feature has to clear that bar, not just be useful to someone, somewhere, occasionally.

The upside of no

Every "no" above is load-bearing. No plugins is why a fresh clone runs. No WYSIWYG is why the whole thing works over SSH. No own comments is why there's no database, and no database is why backup is tar and migration is a for loop.

This list isn't an apology. It's most of the design.

Read more

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

Threads is genuinely feasible, and the design is sitting in a drawer. But look at what "feasible" means here:

Publishing and reading replies to your own posts works — server-side only, through a registered Meta developer app, with OAuth and 60-day tokens.— the Threads API docs, summarized

In practice that's a cron job refreshing tokens so they never silently expire, and comments cached into JSON with roughly a half-hour delay — instead of the live thread Mastodon and Bluesky hand any browser for free. It's all buildable. It's just a lot of standing machinery for a network nobody has asked me about yet. The day someone does, the drawer opens.

Scraping Threads: no, and firmly

The tempting shortcut — skip the API, scrape the web app — fails three ways at once. The app's internals change constantly and no community project maintains a stable interface to them. Meta blocks datacenter IPs and forbids automated collection in its terms, so shipping a scraper in a community engine means handing every user a feature that breaks without warning and can put their account at risk. And the paid scraping services fail the same test with extra steps: per-request pricing, your data flowing through a third party, and a dependency on someone else's ongoing legal cat-and-mouse.

The scoreboard

Mastodon and Bluesky are in because they pass the boring test: free to post, free for a browser to read a public thread, no tokens with expiry dates in the critical path. Any network that starts passing it is welcome. Any network that stops — well, now you know the exit criteria too.

Read more

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.

A drop like that almost never means "I deliberately deleted half my blog." It means a typo in a path, an empty content directory, a build that crashed halfway. Without the guard, --prune would cheerfully delete the rest of your live site and upload the wreckage.

And it's symmetric: a sharp increase trips the same wire, because a site that suddenly doubled usually means a duplicated tree or a badly merged import, not a very productive afternoon.

The escape hatches

  • --dry-run shows exactly what would upload, change or be deleted — and touches nothing. Here's today's, after editing two posts:
Deploy web -> Surfer: https://blogsh.app  [DRY-RUN]
  62 file(s) total, 10 new/changed, 52 unchanged (skipped)
  [dry] index.html (54990 B)
  [dry] posts/2026/no-gems-one-asterisk/index.html (14852 B)
  [dry] posts/2026/why-not-x/index.html (14584 B)
  [dry] rss.xml (38156 B)
  [dry] search-index.json (29072 B)
  ...

Two posts changed, so ten files upload: the posts, the pages that list them, the feed, the search index. Everything else is a checksum match and stays home.

  • --force is the "yes, I meant it" switch, for the day you really do delete a hundred posts.
  • --prune is the only destructive flag in the toolbox, and it's opt-in. (On the git backend every deploy is a snapshot anyway, so pruning is implicit — and reversible.)

One honest side effect

Do a bulk import — or publish a fourteen-post series in one evening, as this site just did — and the growth guard trips too. Again, real output, from this site's launch night:

❌ Stopped: public.nosync/ has 63 files, only 30 were uploaded last time.
   That's a 110% increase -- looks like a duplicated or broken build.
   Check the build output. If the increase is expected (a bulk import/migration), run again with --force.

That's not a bug; that's the guard doing precisely its job on the one occasion you'd forgive it for staying quiet. Check the numbers, feel briefly smug that something checked them at all, and run it again with --force.

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

$ ./blog.sh add

From an empty terminal to a shareable draft, uncut.

One take, fifty-seven seconds, no commentary and no cuts. A phone with a folding keyboard, and ./blog.sh running over SSH on the screen behind it.

What you're watching:

  1. ./blog.sh with no arguments — a menu, arrow keys, one-key answers.
  2. add opens the editor. The post is Markdown with a tiny frontmatter on top.
  3. Saving builds the draft and deploys it to a hidden preview URL — which is where the video ends: the same draft open in a browser on the desktop, and then on a tablet.

That ending is the point. The draft is already a real page on the real site, behind an unguessable URL — reviewable from any device that can open a link, shareable with anyone you want feedback from. The one step the video doesn't show is a single menu choice — publish — after which the announcement toot goes out on its own.

No admin panel logged into, no step you couldn't do over SSH from a train.

Two footnotes for the sceptics. The real-time pace is the argument — that's why there are no cuts. And the whole wizard degrades gracefully: run it in a pipe and it falls back to plain line prompts with no escape sequences, so everything you just watched can also be scripted.

Read more

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:

Homepage in the default blue palette
Default blue — the palette this site ships with
Homepage in a warm cream and terracotta palette
Warm — cream, brown, terracotta accent
Homepage in a monochrome palette
Monochrome — the accent is just a darker grey
Homepage in a high-contrast palette
High contrast — black on white, hypertext blue
Homepage in a deliberately hideous palette
Deliberately hideous — lime, purple, magenta, orange

The hideous one is the proof, by the way: if the config can make it that ugly, the config really is in charge.

What you don't configure

Everything else is derived: card backgrounds, link hover states, the search field, code block backgrounds. Those aren't extra config keys, because in practice they never varied independently — every time I tried a palette, the derived values followed the base ones anyway. Seven keys per mode is the honest number of decisions involved.

Dark mode is the same seven keys again, so light and dark are two palettes, not one palette and a filter.

Two exceptions, on purpose

The banner can optionally override its title and claim colours — banner_title and banner_claim — because a banner is an image, and text sitting on an image plays by different rules than text on a flat background.

And one small thing I like too much not to mention: the scrim that keeps banner text readable only darkens the corner the text actually sits in. Turn the overlays off and your banner stays exactly as you drew it, corner to corner.

Read more

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.

Read more

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:

Browser error page saying the server IP address of distilleryimage1.instagram.com could not be found
distilleryimage1.instagram.com, fourteen years later

Notice it's not even a polite 404. distilleryimage1.instagram.com doesn't resolve at all — the photo is gone along with the entire piece of infrastructure that used to serve it. The post itself is still alive, imported into this engine, minus the one photo nobody can download anymore.

Here's the uncomfortable part: the post was mine. The photo was mine. The URL never was.

What the importer does about it

./blog.sh ships importers for twenty-two sources, and they all follow one stubborn rule: media comes home. Every image, video and audio file gets downloaded next to its post, into media.nosync/<year>/<slug>/, and the post references the local copy from then on. No hotlinks, no third-party CDN, no "this content is no longer available".

A few details I'm fond of:

  • Downloads are measured on arrival. The build refuses to render an image without known dimensions — so the importer simply never writes one.
  • Failed downloads get retried. And when a file genuinely can't be fetched anymore, because the source deleted it years ago, you lose that one image — not the post.
  • The origin becomes a tag. Import an old Tumblr blog and every post lands tagged tumblr. Your old blog turns into a browsable archive instead of a zip file in a drawer.

The numbers

From a real migration of a real fourteen-year archive:

SourceItems in the archiveNotes
Mastodon6,5912,984 replies and 1,059 boosts skipped on purpose
Pixelfed333two accounts, 571 media attachments
Tumblr1,099four separate blogs, importing 1,247 media files that came home with them
Twitter5,3882008–2022, with 1,134 replies and 417 retweets in the pile — and 610 media files

Here's a handful from that archive — photos that spent years on one platform or another, and now live as files next to this post:

The Astronomical Clock in Prague at night
The Astronomical Clock — Hipstamatic, 2019
Prague Castle photographed through a tower filter
Prague Castle — Hipstamatic, 2019
The Emauzy monastery towers
Emauzy — Hipstamatic, 2019
A metal sculpture lit at night
Spholio, a sculpture at night — 2022
A photo taken on a Palm phone
Shot on a Palm phone — 2019, because the archive keeps the weird experiments too

The point

None of this is clever engineering. Downloading a file and putting it in a folder is about as advanced as computing gets. The clever part was done by every platform that convinced us it wasn't necessary.

An archive is only yours if the files are yours. Everything else is a lease — and fourteen years is long enough to watch a few landlords disappear.

Read more

./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.