./blog.sh

A skin that survives git pull

Dressing a site differently used to mean editing a file the engine owns. It worked, right up until the next git pull, which is the moment you find out that customisation and updates were the same file all along.

Four things that needed a modified template are settings now.

Both of these sites are this engine, built from the same commit on the same evening:

Read more

./blog.sh 1.3.1

./blog.sh 1.3.1 is out — release on GitHub.

A bug-fix release about restraint. The scheduled-publish cron now declines the announcements it cannot take back: a backdated post publishes without announcing, and a post that already carries its announcement address is never announced again — not by the cron, not by publish, not by toot, whichever network the address lives on. The queue takes its lock for every write too, so a tick landing mid-edit cannot revert a post it just published.

The first hour of a fresh install stopped misleading: preview and publish print the address that actually opens instead of example.com, the deploy speaks the site's language, and the example config no longer chooses Mastodon for you — a network is a choice.

And emptiness is an answer now, in both directions: an emptied section takes its heading with it instead of crashing the build or failing doctor, and a heading over content that stays can finally be turned off.

Nothing to migrate — git pull, rebuild, deploy. Full notes →

What rots quietly

./blog.sh doctor asks whether the installation is sound and takes a second. It has nothing to say about an archive of four thousand posts in which one picture never downloaded, three permalinks lead nowhere, and two posts claim the same old address.

./blog.sh check walks all of it and says.

What it looks for

Each finding names a post and a slug — something to go and fix — and carries a line saying what to do about it.

Read more

Star it to publish it

The comments under these posts are replies to a toot. That has been the whole system since the beginning, and its one weak spot was always that it is all or nothing: every reply appears, or comments are off.

comments.approval: fav is the third answer. A reply appears under the article once I favourite it, from whatever client I happen to have open.

No queue, no dashboard

That is the point of doing it this way. A moderation queue is a second interface, with a second login, that you visit specially — which means you visit it late, or not at all. Here the interface is the app the comments already live in. I read the reply where I was going to read it anyway, and approving is one tap in that same place.

Two rules keep the result readable rather than merely filtered. My own replies need no star, because nobody stars themselves and half of every exchange would otherwise vanish. And a reply is shown only if everything between it and the announcement is shown too — an approved answer to a rejected comment answers nothing.

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

./blog.sh 1.3

./blog.sh 1.3 is out.

Dressing a site no longer means editing the engine: your own stylesheet, menu, sidebar and lead images are settings now. Posts group into series, pages step out of the stream, and every edit keeps the version before it.

And three commands that read the whole archive: check reports what's broken, stats counts what's there, and export packs it all into Jekyll's layout. The way out ships with it.

#blogsh #ruby #staticsite

Everything that's wrong, at once

Every abort in this engine is correct where it stands. Each one also reports exactly one problem — the first one, from wherever in the code it happened to be noticed. Fix it, run again, meet the next one. Repeat until the thing builds.

./blog.sh doctor reads what is on disk and tells you the lot in one pass, in whole sentences, each with a fix line written for somebody who does not know which file that setting lives in.

It goes after the silent failures

A loud error at least announces itself. The problems doctor concentrates on are the ones that never say anything:

Read more

Walking the archive

A post here recently argued that the wizard menu got shorter because operations on a post belong in the post, not in a menu. That still holds. This release adds a screen anyway, and the reason is worth stating: finding a post is a different job from doing something to one.

./blog.sh browse shows the same posts list does, as a screen you stay in.

What it is

Arrows and Page Up/Down through the whole archive. Filters by type, state and tag, with the count next to each. And a search that filters as you type.

The search is the site's own. Words are ANDed, "a quoted phrase" counts as one, -word excludes, and diacritics never decide a match. The query language and the folding live in lib/search_query.rb, next to a note that it and assets/js/search.js change together — because a query that means one thing in your browser and another in your terminal is worse than having no search in the terminal at all.

It searches the full text of every post, so under the selected row there is a line of that post's own text showing you why it matched.

Read more

Most of your Facebook isn't yours

The Facebook importer was built against a real "Download Your Information" export. The first useful thing it told me was a number I did not expect.

On that export, 95 % of the posts were not written on Facebook. They were mirrored in — from Twitter, from Posterous, from the whole crosspost era when every service politely offered to repost you everywhere else.

They are skipped, and counted

Those posts do not import. Not because they are worthless, but because the platforms they actually came from have their own exports, and those carry the originals: the full text, the real timestamps, the media at the size it was uploaded. A Facebook mirror of a tweet is a worse copy of something you can import properly from the Twitter archive sitting in the same folder.

Import both and your archive doubles, with the lesser version winning half the time. So they are recognized and skipped with a count in the summary, and FACEBOOK_CROSSPOSTS=1 overrides it if you disagree. Wordless check-ins and app stories are counted skips too.

Read more

A palette you can look at first

An earlier post here explains that every colour on this site comes from seven keys in a config file. That part hasn't changed. What has changed is that you no longer have to imagine what those seven values will look like before you commit to them.

Choosing between fourteen hex values is exactly as blind in a wizard as it is in YAML. So ./style.sh shows you.

Seven palettes, one keystroke

Whole palettes now ship in config/palettes.yml, light and dark both:

  • Default blue, warm, monochrome and high contrast — the four from the gallery on this site, whose light modes are exactly what that page showed. Their dark modes are new; the gallery only ever had light homepages.
  • Sunflower, cream and gold with olive links — the golden yellow stays on the tag pills, because yellow text on cream cannot be read and the accent here is above all a text colour.
  • Garden, greens and khaki.
  • Ocean, steel blue over navy.
Read more