Tagphilosophy

Whoever loads last wins

Two real sites wear a look this engine never shipped. sh.cynicky.blog is dressed as Ghost — a full-width lead card, a dotted frame, tiles instead of a plain list. arch-linux.cz is dressed as a Hugo theme, Blowfish specifically — a compact bar, a profile block, cards laid out like someone else's homepage entirely. Neither forked the engine. Neither edited a template. Both still take a plain git pull.

One stylesheet, loaded last

site.extra_css in config/site.yml names a path, or a list of them, loaded after the engine's own colors.css and site.css. That's the entire mechanism — a skin is one file of yours that repaints what's already on the page, and the engine never writes to it, so an update can never conflict with it.

Local paths only: every page carries style-src 'self', and a stylesheet on another host would just be dropped by the browser with no error anyone would ever see — the build refuses a remote path out loud instead, before that silence has a chance to happen.

Read more

The build doesn't know where it's going

Somebody builds a whole site on a laptop, imports a decade of old posts into it, previews every page — and none of it has gone anywhere yet. Nothing in env.sh says where, and nothing else is waiting on that: the build runs, the archive checks out, every page renders at localhost. The build doesn't know where the site is supposed to end up, and it doesn't need to.

The same build can start on a laptop, move to a rented VPS a year later, and land on a Cloudron instance after that. Nothing in the content pipeline changes between any of those moves. What changes is the answer to one question — where should the files go — and six things can answer it.

What decides it

DEPLOY_BACKEND in env.sh names the target; the values under it depend on which one. Everything past that line is identical across all six. Each backend keeps its own manifest — the SHA-256, size and modification time of every file it has already uploaded — so a deploy only ever sends what changed since the last one. deploy-web.sh --dry-run reads that manifest without touching anything.

Here's what that prints, run just now against this site's own backend:

Read more

Written on a train

A reader who was thinking about moving to this engine wrote to say he would stay where he was. He manages his blog from a phone, on the road, and a blog that can only be worked from a terminal was no use to him however fast it built. He was right.

1.6 answers him with two things, and with one thing it refuses to do.

A page on the blog itself

Set write: true and the build publishes a small editor at /write/: a title, the text, tags, photographs and video, each picture with its description. It wears the blog it writes to — its name, its palette, its language — and it offers the blog's own tags as you type, the ones used in the last twelve months first, so a tag is tapped rather than spelt a second way. A preview shows the post in the blog's own stylesheets. A row of marks above the text wraps what is selected and comes off again on a second tap. Above the send button, a Draft | Publish switch.

Read more

An archive you cannot see is not empty

This site's approved comments and its post counters are two small JSON files, refreshed by a cron job every half hour and uploaded with the rest of the site.

Through 1.4, a tick that ran while the archive was not there would narrow both files to nothing and upload them. An absent or empty content directory answers with an empty list and no error, and an empty list reads exactly like "every announced post was deleted". The live site lost every approved comment and every counter at once. Exit 0, nothing said, nothing mailed.

The archive lives on a synced volume. A volume that has not mounted yet, or a working copy whose files are still arriving, is not an unusual Tuesday.

Not being able to tell is a reason to keep everything

That sentence is the fix, and it now runs in two tiers.

If the content directory is not there — or if the archive reads as empty while the previous files held anything at all — nothing is narrowed, and the run says why.

Read more

Posts nobody meant to publish

An importer's worst failure is not losing a post. It is publishing one.

1.5 went through all eleven import adapters and the machinery underneath them looking for exactly that, and found it three times over, in three different files, made the same way each time.

An archive is the whole account

A Mastodon export is not your public timeline. The outbox holds your followers-only posts and your direct messages beside the public ones — and every standalone toot in it was being written as published. On the archive this was measured against that is 141 posts of 2,548, of which 132 are direct messages, each given its own page, its own sitemap entry and its own item in the feed. Nothing in the run's summary mentioned it.

The scope was in the export all along, exactly where ActivityPub puts it. Public in to is public. Public in cc is unlisted — a real address, out of the listings. Followers-only and direct become drafts: that keeps the author's words in the archive and off the site, which is the only reading of "not public" that cannot publish something by accident.

Read more

Tested like an enemy

1.4 went through four adversarial audit rounds before the tag: fleets of independent reviewers over real archives — real exports, a real 4400-post site, real deploy targets — each told to break the engine rather than confirm it. The findings numbered over a hundred. Every one was reproduced before it was fixed, and every fix is pinned by a test that demonstrably fails on the code it fixed.

The pattern that kept repeating

The uncomfortable lesson: each round's fixes were where the next round found its bugs. A fix for a comment-parsing rule deleted a hundred lines of somebody's config; a fix for that hid a widget's answers; the recovery advice written to save posts could destroy one. Four times in a row, the newest code was the most dangerous code in the tree — which is exactly why the last round audited nothing but the fixes themselves. It caught three regressions of mine before any user could.

Eighteen files

Read more

What a crash may cost

The queue can now swap two scheduled posts that share a slug across two years — with their media and their edit history riding along, because those are keyed by year and slug exactly like the post file. That sentence is short; making it true was most of a release cycle. The interesting part is not the swap. It is what happens when the power goes out in the middle of one.

The budget is zero

A move is several renames, and a crash can land between any two of them. The rule 1.4 holds to: whatever the moment, a crash may cost you the move — never a post. Files step aside under parking names before anything writes; a failed write puts everything back, byte for byte; a hard kill that strands a parked file leaves it where check finds it and says truthfully what it is.

The advice is part of the system

Read more

My fixtures agreed with me

The importers had a good test suite. Every source had fixtures, the fixtures were parsed, the results were asserted, and everything passed.

Then I ran the importers over three archives I had not made: a Ghost export of 118 posts and 419 images, a Hugo tree of 77 files, and a WordPress WXR of 194 items. Alongside them, a fourth that is entirely mine — this engine's own export, read straight back in — because that one is not asking whether the code survives a stranger, it is asking whether the two halves of a round trip still agree.

The three foreign ones were the point, though. Fixtures had agreed with the code because both ends of them are mine.

The picture nobody failed to download

{{< figure >}} is how Hugo writes a picture, and the Liquid stripper ate it as a Liquid tag. Both of the ones in that archive pointed at files lying right next to the article in its page bundle: nothing to download, nothing that could fail, and they were gone without a line in the summary — because nobody had tried to fetch them, so nobody had failed.

Read more

The way out ships with it

An engine that cannot hand your writing back is a hosting service with extra steps. The importers have always pointed one way — twenty-two sources, everything coming home. ./blog.sh export is the other direction, and it should have been there first.

What comes out

The whole archive as a tree of markdown files with YAML front matter, in the layout Jekyll reads: _posts/2026-05-01-slug.md, _drafts/, pages at the root, media under assets/<year>/<slug>/. Old addresses travel as redirect_from, in the shape the jekyll-redirect-from plugin already understands, merging both kinds — where the post lived on the platform it came from, and where it lived here before a rename. Point a static generator at the folder and it builds.

./blog.sh export ~/my-blog-export        # everything, drafts included
./blog.sh export ~/public-copy --no-drafts
./blog.sh export ~/somewhere --dry-run   # counts, writes nothing

This site, counted rather than written:

Read more

A blog engine that became an archive

The first post on this site is about a photo I put on Instagram in 2012. The link still works. The photo doesn't.

That post was about one archive — mine — and one rule: media comes home. This one is about what happened when that rule was applied to every platform I could find an export for.

The roster is closed

Twenty-two sources import now, in three groups.

Social networks: Bluesky, Facebook, Instagram, Mastodon, Pixelfed, Threads, Twitter/X.

Blogs and publishing platforms: beehiiv, Blogger, Ghost, Jekyll/Hugo and any markdown folder, LiveJournal, Medium, Movable Type and TypePad, podcasts (any feed with audio), Squarespace, Substack, Tumblr, Wix, WordPress and any RSS or Atom feed.

And dead sites: the Wayback Machine, for blogs whose platform no longer exists at all.

Read more

The guard that switched itself off

This site's deploy has two guards. If a build suddenly has far fewer files than what's live, or far fewer bytes, the deploy stops. They exist because a broken build looks exactly like a deliberate one to rsync --delete, and a static site is only ever one confident sync away from being erased.

In 1.0 they could turn themselves off. Permanently. Silently. Here is how, because the shape of this mistake is more useful than the fix.

The reference was the wrong thing

The guards compared the new build against the deploy manifest — a record of what's on the target. Reasonable, until an upload fails. A failed upload leaves the manifest out of true, and a guard measuring against a record it knows is wrong would fire on every subsequent run.

So there was a marker: after a failed run, stand the guards down until a clean run comes along and restores the reference.

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:

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.

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.

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

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

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:

Read more