Tagbuild

One line of colour

A reader changed one border colour in site.css, ran a rebuild, and waited while the build produced, byte for byte, every page that was already there. He asked why. The answer was one word in one list, and 1.7 takes it out.

What a build writes down

Since 1.6 the build keeps a record of what went into each page it wrote -- the post, the posts around it, the labels, the numbers a page counts. On the next build a page whose record has not moved is not rendered at all. Not rendered and then found identical: not rendered. The summary at the end says how many pages were left alone, the first number to look at when a build is slow.

The record holds only while the engine that wrote it is the same engine, so it hangs off a fingerprint of what the engine is made of -- the files under templates/, lib/, build/, locales/ and config/, hashed by contents rather than by timestamp. Change any of it and the record is discarded entire, because any of it can change every page.

Why the colour cost the archive

Read more

Everything else in 1.6

Five posts have covered the things 1.6 is about. This one is the rest, in the order an operator meets them.

A way out of the trash and the versions

Both stores had a way back — restore, and the version picker in the post's properties — and no way out, so both grew for years and nothing said by how much. empty trash and empty versions each print what they are about to remove, in items and in megabytes, and require that count typed back before anything goes. empty versions keeps each post's newest version, because versions exist to answer "give me back what I just overwrote". doctor now notes a trash with posts in it, as a note rather than a fault: on the installation this engine was built around, the only way to see it was du on the server.

A photograph stored once

public.nosync/ used to hold a second copy of every picture. It holds a hardlink now, so the pictures take half of what they did — 1.8 GB on this project's installation. It saves nothing in a backup, and that was measured rather than assumed: 200 files under two names, 101 MB on disk, 201 MB in the backup. Nothing to do; the first build after the upgrade makes the links.

Read more

A post handed over as a file

Until 1.6 a post came from a person at a terminal. The wizard asked its questions, opened an editor, asked what to do at the end. Nothing else could write one: not a script, not a cron job, not a phone.

./blog.sh add post.md

does the same work with the markdown handed over instead of typed. No editor, no questions, no dialog at the end. A bare filename is looked for in incoming/, so the file can arrive by the same upload as the photographs, and it is deleted once the post is written.

Where the wizard would ask, this refuses

A person can be asked. A script cannot, so every question becomes a refusal that writes nothing: a photo that has not finished uploading, an empty body, a file that is not text, a second filename where one was expected. The alternative — guessing — was measured on the old code, where a byte-order mark in front of the header was taken for body text: three invisible bytes from a Windows editor and the post arrived with no title and no tags, named after the words "title:" and "tags:", exit 0. 1.6 reads the header through them.

--json: one object, nothing else

Read more

The time between deciding and the site saying so

Publishing a post used to cost the size of the archive, not the size of the change. A post dated today alters a dozen files. The build rendered every page in the archive and read every one back off disk to find that out.

On the archive this engine was built around — 6,639 posts, on the server it lives on — that was 47 seconds for a rebuild that changed nothing, and 48 from pressing publish to the post being out. Fine for one post in the morning. Less fine for fixing a typo in ten of them.

What the build remembers now

1.6 keeps a record of what went into each page: the post, the template, the locale, the configuration, the engine itself. On the next build it renders only the pages whose record no longer matches, and the rest cost one stat each instead of a full render and a read-back.

The same archive, measured the same way: a rebuild that changes nothing takes a seventh of what it did. Publishing a post dated today takes about two fifths — 16 seconds of build and 2 of upload, where it was 48 — and the build says so itself: Pages already up to date, not rebuilt: 10,160. The ratio is what travels; the seconds are one machine's.

Read more

Everything else in 1.5

Five posts covered the shape of this release. Here is the rest of it, a paragraph each.

Read more

Cut before it is written

A listing card used to be the whole post. The engine rendered every block of it into the page, and the stylesheet clipped the result at 500 pixels.

On the real front page that meant fourteen cards carrying between 794 and 2,616 pixels of content and showing 500. Of the page's 50,659 characters, 49,312 sat inside the clip. Thirty-three of its 154 focusable elements could be reached with the keyboard while being invisible on screen — links a sighted mouse user could not see and a keyboard user could not skip.

1.5 cuts the card before it is written. The stylesheet hides nothing.

That page went from 89,268 bytes of HTML to 41,155. Its largest card, 7,463 characters, is now 2,566. And "read more" appears exactly when something did not fit, rather than sitting under a card that was already showing everything it had.

The budget is in pixels, not characters

Read more

Two pages the site built out of itself

Until now this engine could show you ten posts at a time and nothing else. /page/128/ is a real address on a large archive, and it tells you nothing about whether you have landed in 2009 or in 2014 — pagination is anchored from the oldest post, so the number moves under you every time something is published. The only complete list of anything lived in the terminal.

1.5 adds two pages that read the archive instead of walking it.

/archive/ — the shape of the whole thing

A row per year, with a strip of twelve months beside it. A month is shaded in one of four steps rather than simply lit or dark, because "has posts / has none" is not the question worth answering: on the reference archive a month holds anywhere from one post to eighty-seven, and drawing those the same would hide the one thing a map is for. /archive/<year>/ is one line per post, by month.

Two levels and no more. A third would be some 280 pages of individual months — a great deal of building for a question nobody asked.

Read more

The other half of check

./blog.sh check reads the whole archive and says what is wrong: media a post asks for and hasn't got, links to addresses nothing answers at, two posts that would be served at one address. Through 1.3 that was where it ended — a list of findings and an exit code, with the fixing left to you and a text editor.

--repair walks it with you

check --repair is the checker's other half: for each finding it offers the one repair that finding allows — an old address written into the target post's redirect_from, a relative link rewritten to the address it means, an orphaned file moved to the trash. Nothing is applied without a keypress, nothing is deleted outright, and a finding with no obvious answer — two posts colliding, an image only a human can judge — is shown and passed over rather than guessed at.

It proved itself before it shipped: on a real archive it took 238 findings down to 42, and the 21 repairs it proposed matched what a person had already decided by hand, one for one.

--json says everything

Read more

Deploy in your own language

The wizards were translated from the start; the narration around them was not. A Czech site watched its own deploy go by in English, one line above a Czech sentence — build warnings, announcement failures, import errors, the queue's repair instructions, all of it. Not broken, exactly. Just a house where half the rooms answer in a different language.

Sixty sentences moved home

1.4 finishes the job: the deploy's whole narration — header, progress, the closing tally, every degradation notice — plus all eleven build warnings, every Bluesky and Mastodon failure, and the sentences an import prints when a source dies. English, Czech and German carry the same set, and the suite enforces the parity, so a key cannot exist in one language and abort in another.

What stays as it arrived

The diagnostics inside the sentences do not get translated: an HTTP code, a server's raw answer, an exception's text. Those are evidence, and evidence is quoted, not paraphrased — the sentence around them is the site's, the thing inside it is the network's.

Read more

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

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

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

An old link still knows the way

The first post on this site is about a photo that disappeared from a 2012 blog entry while the link to it kept working. Link rot is the reason this engine exists, so an engine that quietly broke its own URLs would be a bad joke.

Renaming a post changes its address. That's unavoidable — the slug is the URL. What's avoidable is the old address turning into a 404.

What renaming does now

Rename a post — r in the properties dialog — and the post records its old address inside itself. The build then keeps a one-page redirect standing at every address the post has ever had.

The link in a two-year-old toot keeps resolving. The link somebody put in their own blog post keeps resolving. You get to fix a slug you regret without the fix costing you every reference to it that already exists in the world.

The redirects belong to the post

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.

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

Read more