Series./blog.sh v1.1

./blog.sh 1.1

./blog.sh 1.1 is out.

Pin a post to the top. Schedule into publishing slots that queue instead of colliding. Attach files as download cards. Rename a slug without breaking a link — the old URL redirects forever. One dialogue for everything you can do to a post.

And deploy guards that measure the build against the build, so a failed upload can't quietly switch them off.

The post that stays

A blog's front page is the only page that's a statement. Everything else is chronology doing its job.

So pinned: true in a published post's header now holds a copy of it at the top of the first listing page. That's the whole feature, and the interesting part is everywhere it deliberately doesn't apply.

Only the front page

Type listings, tag listings, the RSS feed and the sitemap stay strictly chronological. A pin is a claim about what a visitor should read first, not a rewrite of when things were written. Someone subscribed to the feed gets posts in the order they happened, and a pinned post doesn't jump their queue every time it's toggled.

What happens as it ages

While the post is still recent enough to sit on page 1 by date, it appears exactly once — pinning it doesn't print it twice on the same screen. Once it has aged onto page 2, it appears in both places: at the top of page 1, and in its own chronological spot on page 2.

That second half is the part I'd have got wrong if I hadn't thought about the archive. A pinned post that vanished from its own date would leave a hole in the record, and the record is the point.

What it costs

One or two files in a deploy. Pagination here is anchored — page 2 doesn't renumber itself because page 1 changed — so toggling a pin doesn't reshuffle the archive behind it.

Visually it costs even less: the pinned copy carries a small mark in the corner of its date badge, drawn in the neutral colour pair the badge already inverts to on hover. No new colour entered the palette. Seven keys are still seven keys.

Turning it on

Put pinned: true in the post's header, or press c in the properties dialog. Every list and picker marks it [PINNED] afterwards, so you can't forget which post is currently doing the talking.

Read more

Some posts are downloads

A photo in a post is a bare filename in an image line. It always has been:

![A view from the window](window.jpg)

So a file attached to a post is the same line without the exclamation mark:

[Reading notes, 2025](reading-notes.pdf)

A whole line that is nothing but a link, pointing at a bare filename with a known extension, is an attachment. It gets staged through incoming/ like a photo, stored next to the post like a photo, and it never leaves your site.

A line with a URL in it stays what it has always been: a link. The difference is the bare filename — the same rule photos already follow, so there's nothing new to learn.

An attachment renders as a card carrying the label, the extension and the size. The size is there because a download deserves to say what it costs before you commit to it — a 40 MB PDF on a phone on mobile data is a different proposition from a 200 kB one, and the reader should get to make that call.

Over about 50 MB you get a warning when you save. Not a refusal — sometimes a big file is exactly the point — just a note that you're about to ask a lot of somebody.

The extensions that count: .pdf, .zip, .tgz, .epub, .txt, .md, .ics, .gpx, .csv. A whitelist, because the engine should only publish files it can honestly describe.

And a new kind of post

A short line plus a file makes the post a document — the same way a short caption plus a photo makes an image post. Write more than a caption and it's an article that happens to have an attachment.

The first document you publish makes DOCUMENTS appear in the navigation. Nothing to configure: the menu has shown only the types a site actually has since 1.0, so the section arrives with its first tenant and would leave with its last.

Publish and forget. That was the whole brief: a PDF, a GPX track, a calendar file — things worth putting somewhere permanent without writing an essay around them.

This post has one

Everything on this site, twenty-one chapters, in a single file — set in the two typefaces the engine bundles and coloured with its seven palette keys, because a document about a blog engine may as well look like the blog.

PDF./blog.sh — what it does, and why it doesn't do the restPDF · 1.3 MB

That card is the feature. Label, extension, size — and a file that lives here, next to the post, rather than at an address that might stop answering one day.

Read more

Three drafts, three mornings

Writing doesn't arrive evenly. You get an evening where three posts want out, then nothing for a week. Publishing them the way they were written means three posts in one night and silence afterwards — which is a worse blog than the same three posts spread across three mornings.

So a site can now say when it usually publishes:

publishing:
  slots:
    - "mon 09:30"
    - "wed 09:30"
    - "fri 09:30"

Or, if you write more than that, a single "daily 09:00".

With that in the config, scheduling a draft offers you the next slot no other scheduled post already occupies. Three drafts written in one evening are offered Monday, Wednesday and Friday, in that order, without you counting days.

It only ever offers

This is the part I care about most, because a queue that takes decisions away from you is a queue you fight.

Typing a date overrides the offer, always. A post you hand-schedule for 14:17 on a Tuesday blocks nobody — it isn't in a slot, so the queue routes around it. And nothing ever moves a post that already has a time. The slots suggest; you decide.

Without the key in your config, the prompt is exactly the one that was there in 1.0.

The offer explains itself

An offer of Sunday, on a site with a Saturday slot, reads like a queue that skips Saturdays. So the offer names the slots it walked past and who is holding them:

next free slot: Sun 09:30
  Sat 09:30 taken by "the-post-that-stays"

A scheduled draft's properties print the whole queue for the same reason. A queue you can't see is a queue you don't trust.

The unglamorous half

Two drafts landing in the same slot would be worse than no queue at all, so the slot search skips anything already claimed — and it does that across a daylight-saving change too, which is where the first version double-booked. Clocks that move an hour twice a year are the reason scheduling code is never as short as it looks.

Read more

The photo your iPhone won't show anyone

An iPhone shoots HEIC by default. Safari renders it. Chrome doesn't. Firefox doesn't. So a photo straight off the phone, attached to a post and published, is a photo most of your readers will never see.

The old behaviour was the worst possible one: the file went in, the build couldn't measure it, and the image quietly vanished from the page — taking its caption with it. No error. Just a post with a hole where a photo was.

Refused, with the command you need

Attaching a HEIC now stops the save and prints the exact conversion command for the machine you're standing at:

sips -s format jpeg photo.heic --out photo.jpg      # macOS
heif-convert photo.heic photo.jpg                    # Linux

Your file stays exactly where it was, in incoming/. Nothing is deleted, nothing is guessed at. You convert it and carry on.

Or converted, if you ask

Set media.convert_heic: true and the engine converts it for you, using whichever tool it finds — sips, heif-convert, magick, vips. If it finds none, it falls back to the refusal above rather than pretending.

That's off by default on purpose. Converting means the file on your site isn't the file you handed over, and that should be a decision you made, not a thing that happened to you.

Detection is by content

Renaming photo.heic to photo.jpg doesn't get it past the check — the engine reads what the file actually is, not what it's called. Phones and export tools mislabel files often enough that trusting the extension would just move the silent failure somewhere less obvious.

The rule underneath

An image must never disappear quietly. If the engine can't handle a file, it says so, names the file, and leaves it where it is. A build that drops a photo and finishes with a cheerful summary has lied to you, and you'll find out months later when someone mentions the empty space.

(If you'd rather sidestep all of this: Settings → Camera → Formats → Most Compatible makes the phone shoot JPEG.)

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

They live and die with it. Unpublish the post and its redirects come off the site with it — a redirect pointing into nothing is just a slower 404. Publish it again and they come back, because the old links didn't stop existing while the post was away.

That's the same rule the announcement toot follows, and for the same reason: everything a post owns should leave and return with it, or the site accumulates debris nobody remembers creating.

The small print

The redirect list is the post's own history, so it never contains the post's current address — a post redirecting to itself is a loop, and the build says so if a date edit across a year boundary ever creates one.

And slugs have a length limit now, with a plain message when you exceed it. The first version handed you a filesystem error instead, which is a technically accurate way of saying nothing useful at all.

Read more

The menu got shorter

The wizard used to list ten things you could do. It lists five now, and the engine can do more than it could before.

That's not a paradox, it's an admission: eight of those ten were not activities. They were operations on a post, wearing the costume of a menu item.

Everything about a post, in one place

./blog.sh props <slug> — or, in the wizard, pick a post and press v — shows what there is to know about it. State, type, tags, whether it's pinned, whether it's been announced and where. If it's scheduled, the whole publishing queue.

And the actions live there too, next to the facts they act on. A draft offers publish and schedule. A published post offers unpublish, re-announce, pin or unpin, rename, delete.

You no longer go to a menu, choose "unpublish", and then hunt for the post. You go to the post and see that unpublishing is one of the things it can do right now.

What stayed where it was

Type and tags are still edited in the frontmatter of edit, prefilled with their current values, because they're text you write. The pin is a switch rather than a value, so it toggles right in the dialog with c — and the header line keeps working, for anyone who'd rather type it.

The five that remain are the ones that really are activities: write, edit, import, list, rebuild.

One guard worth mentioning

The dialog acts on the post it read when it opened. If the scheduled-publish cron fired in between — you opened the properties of a draft, went for coffee, came back at 09:31 — an action taken on that stale copy could have reverted a post the cron had just published, dropping its announcement URL along the way.

edit already guarded against exactly that. Now the dialog's actions do too. It's the kind of bug you only find by asking "what if something else touched this file while I was looking at it?", which is a question worth asking of every screen that holds state open.

If you script

The wizard's numbering changed with the shorter menu, so a scripted printf "4\n" | ./blog.sh picks something different than it did in 1.0. The CLI commands are the stable interface — none of them changed, and none of them will.

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.

You can see it already. When the failure is permanent — a file the host keeps refusing, expired credentials, a target that no longer exists — no clean run ever comes. The marker never lifts. The guards are off, and nothing says so. A build collapsing from 7,500 files to a handful would have been mirrored faithfully, --prune included.

The fix was to stop measuring the target

They now compare the build against the last build that was accepted, recorded before the first byte moves. That number doesn't care whether the upload then succeeded, failed, or died halfway — so there's no longer anything to stand down, and no marker to get stuck.

The reference stopped being "what's out there" and became "what I last agreed to". Nothing else had to change.

Three more things came out of the same review

They also fired when they shouldn't. Twenty per cent of a 32-file build is six files, so publishing two posts at once could abort a deploy — inside a flow ./blog.sh runs for you, which has no way to pass --force. The percentages carry absolute floors now.

Bytes are guarded in both directions. The same file count with every page nearly empty used to be invisible. A byte drop stops the deploy; a byte increase only mentions itself, because attaching media is authoring, not a fault.

An empty build is refused outright. With an empty manifest, it used to sail through every check that existed.

Why write this up

Because "we found a bug" is worth less than "here is the reasoning that produced it". The bug wasn't sloppiness. It was a patch that solved the problem in front of it and created a worse one behind it, and it survived because the failure mode was silence.

The lesson I'm keeping: a safety mechanism that can be disabled by the thing it's protecting against isn't a safety mechanism. It's a suggestion.

Read more