Tag: philosophy

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

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

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

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

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 eight 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