Tagcontent

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

A badge that says what a post is about

Every post wears a date badge with an icon in it, and the icon says what kind of post it is: text, a picture, a video, a quote. On a blog where nearly everything is text, that icon is the same on nearly every card. Somebody running this engine put it plainly in issue #45: one sees the generic icon so often that it becomes invisible, or clutter.

1.6 lets a tag carry an icon instead.

tag_icons:
  - tag: "build"
    icon_svg: '<svg viewBox="0 0 24 24" …>…</svg>'
  - tag: "comments"
    icon: "chat"

It replaces rather than adds

On a post's date badge the tag's icon takes the place of the content-type icon; in the heading of /tag/<name>/ it takes the place of the generic tag icon. That was the requester's first point, and the one worth asking about rather than guessing: two icons side by side on a 60-pixel badge is not a badge, it is a puzzle. A tag with no entry changes nothing, so the generic icon is still there for everything you have not named.

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

A slug is an address, not a name

For most of this project's life, a post without a title had two names. Its own page called it by its date. Everything else — the browser tab, the link card somebody shares, every item in the feed — called it burtiky-opekame-hipstamatic-oggl-jane.

That is not a corner case. On the archive this engine is measured against, 2,754 posts of 4,418 carry no title. Sixty-two per cent of a site, going out into the world under an address.

A slug is made out of the words. It is not the words.

A slug is a machine's answer to "where does this live": lowercased, stripped of diacritics, joined with hyphens, cut to the first eight words. Every property that makes it a good address makes it a bad name. It has no capitals, no punctuation, and no idea where the sentence it was cut from was going.

1.5 takes the name from the same place the slug came from — the post's own opening — and stops in a better place.

Where it stops

Read more

The link is the player

Embedding a video usually goes like this. Open the platform, click Share, click Embed, copy the blob of HTML it hands you, paste it into your post. What you have just filed in your own archive is somebody else's markup: an iframe, sometimes a script, a couple of tracking parameters and a class name from a design system you don't use. And when the platform changes its embed path — they do — every post you ever wrote that way breaks on the same afternoon.

Here you paste the address you would send a friend.

Two of them, actually playing

A video, with a caption:

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

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

Your camera writes down where you were

Take a photo on a phone and it records the spot you stood on. Not as a guess — as coordinates, accurate to a few metres, in a block of metadata that travels with the file wherever it goes.

Social networks strip that on upload. They have done it for years, quietly enough that most people have stopped thinking about it. A static site has nobody to do it: blog.sh copied your photo into the archive byte for byte, and nothing in the engine had ever looked at metadata at all. So a picture of a cat on a windowsill published the windowsill.

From 1.2.1 it doesn't. A photo loses its location on the way into the archive — when you add it to a post, and equally when it arrives through any of the twenty-two importers, because they all end up in the same write.

Only the location

The camera, the lens, the exposure, the moment the shutter opened: those stay. They are your own record of your own photograph, and an archive that silently threw them away would be answering a question nobody asked.

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

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

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.

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.

Rendered as a card, not a link

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.

Read more

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.

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