Text

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

./blog.sh 1.8

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

A release about what happens when something goes wrong. Every page, feed and index is now written to a temporary file and renamed into place, so a save interrupted by a full volume or a build stopped mid-write no longer leaves a page at half its length, or a post at 0 bytes with the old text gone. A cold build costs about a fifth more for it; a cached rebuild does not, so an ordinary day of publishing costs exactly what it did.

Arriving twice stopped meaning happening twice: a post sent from the phone twice, or one receipt delivered in two copies at once, is one post now, and re-importing this engine's own export no longer turns 1200 posts into 2400. And an address is no longer trusted just because it looks like one — a ../ in a slug, a media filename or a redirect target stays inside the site, a local deploy stays inside its directory, and a feed's redirect cannot send a fetch to localhost.

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

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

The last thing a recorder writes

Someone opens a post with a video in it and presses play. The connection is fine. The video is not large. Nothing happens for a while, and then the video starts all at once, as if it had been waiting for permission.

It had been waiting for the index.

Why it sits at the end

An MP4 is a handful of boxes laid end to end. One of them, mdat, is the picture and the sound. Another, moov, is the index: which frame is at which byte, how long the thing runs, what codec to hand it to. A player needs moov before it can show a single frame.

Read more

A bicycle nobody had to draw

The example configuration this engine ships carries a bicycle: a tag called kolo, and under it an <svg> written out by hand. It is there because there was nothing else to put there. tag_icons took the name of an icon the engine ships or a drawing of your own, and the ones it shipped were the eight content types — text, image, video, link, audio, quote, document, chat. A tag about cycling could wear the glyph for "audio", or somebody could spend an evening with a coordinate grid.

1.7 puts fifty-five drawings in between, and gives each of them a name.

Fifty-five nouns

Counted in lib/icons.rb: the eight content types, fifty-five more, sixty-three names a tag may ask for. Grouped by what a blog turns out to be about.

Read more

The address a post is about

Somebody sends you an article. You read it, and you have two hundred words to say about it -- not a review, the thing you noticed and one sentence about why. The post is mostly the other page, and the address it is about has to live somewhere.

Until this release the honest answer was: in a paragraph, like any other link. The card such a post opens with existed as a block -- the importers make them, out of Tumblr and out of Bluesky -- but nothing anybody could type produced one. The writer that turns a post back into markdown had no syntax for it, so edit on a link post offered to drop the card, and add <file> could not make one at all. This project's own release posts stopped being link posts after 1.3 for that reason. The tool had changed; the intent had not.

Three lines in the header

Read more

What a post is, not what it says

Five parts of a series written over a spring, and the third went out without its series: line. The site did what it was told. Part three sat at its date and nowhere else: off the series listing, out of the links from one part to the next, and out of the count the other four wear: part four's page called itself part 3 of 4, standing in the slot the missing post should have had. The fix was one word.

Getting to that word meant opening the article.

That is what edit does. It reads the post out of its file, writes the whole thing back out as markdown, opens it in an editor, and parses what comes back. For a post that is paragraphs and photographs the round trip is faithful and nothing is at stake. For a post carrying something markdown has no form for, it is not.

Seven rows

props <slug> gains [e] -- on a draft, on a scheduled post and on a published one alike.

Read more

The post that waited for a terminal

The post is written on the tram: a title, four paragraphs, two photographs with their descriptions. The switch above Send says Draft, which is what it says unless it is moved. Send, and the answer card comes back with the preview address. Then it sits. Putting a draft out was a command, and a command wants a terminal, and the tram has none.

1.6 asked for that decision at the wrong end: publish at the moment of sending, before the blog has rendered anything, or see the blog's own page and then find a keyboard. 1.7 closes that, and then closes the road the answer takes back.

One file, down the same connection

The answer card for a draft now carries a Publish button. Pressing it sends one file, called publish.txt, holding the slug and nothing else -- through the same share sheet, the same two shortcuts, the same SSH connection the post itself took.

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