Text

Ten smaller things

The rest of this release is ten additions that each need a paragraph rather than a post.

Pages

page: true gives a post a permanent address at the root — /about/ — out of the listings and the feeds, but in the sitemap and the search index. Ghost, WordPress, Squarespace and Substack import them as pages now instead of skipping them, and the import says where they landed and that nothing links to them yet. The engine will not write a nav: entry on your behalf; where a page belongs in your menu is your decision.

Series

series: groups posts and series_part: orders the one you published out of turn. Each series gets a listing, and every post in one links to the previous and next part — within the series only, never spilling into the archive around it. A draft's preview says whether its series name joins something that exists or founds something new, so a typo is caught while it is still free to fix.

Unlisted

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

./blog.sh 1.3.2

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

A GoToSocial release, and both fixes came from the first site to wire blog.sh to a GoToSocial instance: arch-linux.cz runs its own, instead of pointing at a Mastodon account. My own tests had Mastodon on both ends, so they could never have found either.

GTS speaks Mastodon's API with its own accents, and two places in the engine had taken the Mastodon dialect for the whole language: the feed widget asked every host for JSON first and got exactly that, a JSON Feed the XML parser then reported as malformed; and doctor refused the account id its own setup had pointed you at, because it demanded digits where GTS answers with a 26-character ULID.

One small thing for skins came from the same site: a listing page now says whether it is the first one. <body> carries page-first or page-cont, so a stylesheet can tell the front of a listing from its /page/N/ continuations — something CSS cannot work out on its own, because it cannot read an address.

Read more

Ten versions back

The engine has never had an undo. Editing a post rewrote its file, and what the post said an hour ago was gone — not because anything went wrong, but because a save is a save.

Now the previous text is kept before every overwrite, and [v] in a post's properties puts one back.

Before every overwrite, not just before an edit

A re-import is an overwrite too, and that turns out to be the case where this earns its keep. Editing a post by hand is deliberate; re-importing a source over a post you have since tidied up is the accident that eats an evening's work, and up to now the only recovery was a backup.

Ten per post, newest first.

Picked by recognising it, not by reading a timestamp

The list is one you walk like every other list in the wizard, and the row under the cursor shows what that version said — its title, or its opening words if it hasn't got one. Ten timestamps tell you nothing about which one is the version you want. Ten opening sentences tell you immediately.

Read more

An import only ever adds

Re-importing an archive used to be a thing you did carefully, at night, after a backup. The run went out and fetched every picture again — pictures the archive already held, byte for byte — and each of those downloads landed on top of a file that was already correct.

That is fixed, and the fix is one sentence: a media file's identity is the address it came from.

The archive already knows

Every media entry records its src. A re-import compares what the source offers against what the archive holds, and fetches only what is missing. Over a complete archive that is nothing at all — the second run of the same import downloads not one picture, and says so in the summary.

The source itself is still read, and that is the honest limit of the claim: an export sitting on your disk costs nothing, while a feed or an API is fetched every time, because there is no other way to find out whether anything changed.

Read more

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