Series./blog.sh v1.3

./blog.sh 1.3

./blog.sh 1.3 is out.

Dressing a site no longer means editing the engine: your own stylesheet, menu, sidebar and lead images are settings now. Posts group into series, pages step out of the stream, and every edit keeps the version before it.

And three commands that read the whole archive: check reports what's broken, stats counts what's there, and export packs it all into Jekyll's layout. The way out ships with it.

#blogsh #ruby #staticsite

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:

Would write to /tmp/bsexport: 43 post(s), 0 draft(s), 0 page(s), and 24
media file(s) (27.6 MB).
9 block(s) markdown has no syntax for were written as HTML: link (6),
audio (1), file (1), video (1). Any engine that passes HTML through renders them,
and this engine reads them back whole -- each one carries its own
definition in a comment above it, which everybody else ignores.

The layout is Jekyll's: _posts/, _drafts/, pages at the root, media
under assets/. Hugo, Eleventy and Astro read it with a line of config;
./import.sh reads it as it stands.

What markdown has no word for

Plenty. An attachment with a label and a size, a video with a poster, a gallery, an embed — markdown has no syntax for any of it. Those blocks go out as HTML with their definition in a comment above them:

<!-- blogsh:block {"type":"file","label":"..."} -->

Any other tool sees ordinary HTML and renders it. ./import.sh reads the comment and gets the block back whole. That is the difference between an export that lets you leave and one that lets you move: export from one machine, import on another, and the installation arrives with its attachments still attachments.

It reads the archive, not the site

No build, no network, no env.sh. It walks content.nosync/ and writes. Which means it works on the day the config stops parsing, on a server you are being evicted from, on a clone you found in a backup — the moments when needing an export is not hypothetical.

The one guard: a directory that already has something in it is refused until you repeat the command with --force, which then writes alongside what is there. An export never deletes anything, at either end. A directory holding only dotfiles counts as holding something — a freshly cloned repository used to read as empty and get written into without the --force the guard exists to require.

Why it is in the engine and not in a script

Because a tool nobody maintains is a promise nobody keeps. The round trip — export, re-import, compare field by field — is in the test suite, and it earned its place there immediately: the first honest run found that an attachment came home as a paragraph with a link in it. Label gone, size gone, block type gone, file orphaned in the tree. Nothing had reported a loss, because both ends of the fixture were mine.

That is the argument for shipping the exit with the engine. A way out nobody ever walks is not a way out — and walking this one, field by field against what went in, is the only reason that defect was ever found.

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.

What it costs, and the costs are real

A favourite is public. On both networks. Approving is also endorsing in the eyes of anyone who looks, and every star handed out over the years is retroactively an approval. A private signal — bookmarks — would not carry that, and is the obvious second mode if this one chafes.

The moment you switch it on, every comment on the site disappears. Moderation publishes what has been starred, and at that moment that is nothing. A site with years of replies goes quiet in one rebuild. Plan the switch for an hour when you can go and star the keepers, and run ./scripts/refresh-sidebar.sh --full afterwards to settle the old posts on the spot instead of within the week.

Approval is not instant. Up to one cron interval — and up to a week under a post older than about three months, since old posts are only refreshed occasionally. "Did I favourite this?" is a question only an authenticated request can ask, and the token cannot be shipped to a browser, so cron reads the thread and writes the answer; the page renders from that. ./scripts/refresh-sidebar.sh --full does it now instead of eventually.

It makes an older promise on this site conditional. There is a post here saying there is no comment database — nothing to migrate, back up, or moderate at 2am. With approval on, only the last third of that stays true. What has been starred is written to a file the pages read, rewritten from the source on every cron run, with no interface anywhere that can edit it: a cache rather than a database, but other people's words on my disk all the same.

And somebody's deletion can outlive itself here. A reply deleted at the source leaves this site at the refresh cron's cadence rather than immediately — under an old post, up to a week later. --full settles it on the spot, but the lag is part of the price rather than a setting, and it is the part that is paid by someone other than me.

It keeps replies off the blog, not off the network. The thread stays public and this site still links to it. Block, mute and report remain the tools for the rest.

The side effect nobody asks for

With the thread already read on the server, the visitor's browser stops contacting the network at all — and the page's content security policy drops its grant to the instance along with it. The live thread was the one client-side exception to no third-party requests from the visitor's browser. A moderated thread is not live, so the exception lapses with it.

Avatars still come from the instance hosting them. Everything else arrives from this domain.

Turning it on

comments:
  approval: fav      # or "off" (the default)

Three things have to be true, and ./blog.sh doctor --online says so when they are not: the sidebar cron has to be running, a Mastodon token needs read:statuses alongside write:statuses — a token without it gets a perfectly good answer with the favourited field left out, which reads as "approved nothing" — and you have to go and star the ones worth keeping. On Bluesky the app password you already have is enough.

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.

  • Media a post asks for and hasn't got, a video's poster included. Usually an import whose download failed. The page renders a hole.
  • Images stored as 1px or smaller. The build treats those as tracking pixels and drops them together with their caption, so the page loses both without saying so. That one had been happening for a while before anything looked for it.
  • Internal links pointing at nothing — typically a permalink left over from an import.
  • Media directories no post owns, left by a deleted post or an import that ran twice. They cost disk, not correctness, so they are warnings.
  • Files in a post's own directory that the post no longer names. An import only ever adds, so a source that drops a picture between runs leaves its file stranded.
  • Two series whose names differ by a character or two — one series with a typo that quietly founded its own. Names differing only in digits are left alone: rok-2025 beside rok-2026 is two year-series, not a mistake.
  • One old address claimed by two posts. Whichever renders last wins and the other's readers land on it.

What it said about this site

Checking this archive

Reading posts: 37/37

⚠️  Post fourteen-years-every-image-came-home no longer names the file
   01.jpg in its media directory.
   Left behind by a picture the source dropped or renamed between
   imports. The post does not reference it; delete it yourself once you
   have looked.

0 problem(s), 1 thing(s) worth a look.

One warning on a small, carefully kept archive, and it is the finding this release added. The fix line guesses at an import, because that is the usual culprit — but nothing on this site was ever imported. That stray file was mine, and its story is duller and more ordinary than any import. I wrote that post one morning; forty minutes later I added a screenshot to the front of it, everything below shifted one place down, and the picture that had been 01.jpg was written again as 02.jpg. Same bytes, same checksum, new name. The post went out that evening carrying six pictures, and the seventh — the old name of one of them — sat in its directory for the next two weeks, 124 kB that nothing pointed at.

Which is the better argument for the check than the one I had. It doesn't care who left the file, and here it caught a case where nothing was lost and nothing was broken — just a copy of a picture the post still has, under a name it no longer uses.

The file is gone now, and running it again says so:

✅ Nothing wrong inside the archive: 43 post(s), with media, links and
   redirects all checking out.

The archive is sound.

It only reports

Nothing here deletes a directory or rewrites a post. The value of a checker is that its output can be trusted, and a checker that also acts has to be trusted twice.

It exits non-zero on errors alone, never on warnings, so it can hang off cron and speak up only when something is actually broken. Long lists are capped at twenty lines per kind — but the counts in the summary are the archive's, not the screen's, so one bad import cannot bury everything else and cannot flatter the totals either.

--online is a different tool wearing the same name

It asks the web about every link that leaves the site, and it takes minutes rather than a second, which is why you have to ask for it by name.

What it is willing to call dead is deliberately narrow: a host that no longer resolves, and a page answering 404 or 410. A timeout, a refused connection, a 5xx or a 403 is the web saying not right now — reporting those would turn one flaky evening into forty findings that are all fine tomorrow. Anything that looks dead is confirmed with a second request before it is believed, because some servers answer a HEAD with 404 and a GET with 200 for the same address.

Verdicts are remembered for a fortnight, so next week's run only asks about links it hasn't seen lately.

The hardest part was not finding things

It was not finding things that aren't there. The first honest runs called a healthy archive broken: every internal link into a listing page, a tag page or a series page came back dead, because those addresses are written by the build and check reads the content. A checker that exits non-zero every night on a sound archive trains you to ignore the night it means it.

One deliberate exception survives that pass. A link to a series with only one published part is still reported, because a series listing exists from the second part on — the address really is empty today, and it starts working the moment you publish part two.

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:

blogsh.app, wearing nothing but the defaults
sh.cynicky.blog: a stylesheet of its own, no sidebar, a lead image on every post and a menu it named itself

The second one uses all four of the settings below and not one edited engine file — git status in that installation is empty, which is the entire point of the exercise.

Your own stylesheet

site.extra_css takes a path, or a list of them, loaded after the engine's own. A skin lives in a file of yours and updates never see it.

site:
  extra_css: /assets/css/mine.css

Local paths only. Every page carries style-src 'self', so a stylesheet on another host would be discarded by the browser with no error you would ever notice — the page would simply render undressed. The build refuses it out loud instead, and doctor names the line it had to skip.

Your own menu

Without nav: the bar is derived from the content types that actually have posts, exactly as before. With it, the site says what belongs there:

nav:
  - { label: "Home", url: "/" }
  - { label: "Photographs", tag: "photo" }

An entry missing either half is skipped rather than rendered as an empty link. An empty list is a decision rather than a mistake — the menu then renders nothing at all, which is also how a site turns the menu off, so there is no second key for that.

./style.sh edits it and does the tedious part: it offers your busiest tags and your pages by name, so you pick rather than type slugs. doctor reports an entry whose target has since gone missing, and the url: "about" spelling that works from the front page and nowhere else.

Two regions, on or off

layout.sidebar switches the right-hand column off and lets the content take the full width. layout.hero lifts a post's first usable image out of the text and runs it above the title — off unless you ask, because it reshapes every post page it touches, and a single post can still decide for itself with hero: in its own header. A tracking pixel is never promoted to lead picture.

They are switches for regions, not for how a region looks. What things look like belongs in a stylesheet, and a key per visual property would turn the config into a stylesheet written in YAML.

All of it from the wizard

./style.sh grew a Layout section, so palette, banner, fonts, menu, regions and your own stylesheets are one conversation. A site can now be dressed entirely from the wizard, which was the whole point of making these settings rather than documentation about which template to edit. Entering a section and pressing through it changes nothing.

The one thing that changed without being asked

The menu bar follows you down the page now. It took over the job of the menu that used to be repeated under the content — which only existed because the bar didn't — so that one is gone, key and all. Two copies of the same menu on one screen is not a preference.

The line that menu used to draw above the footer is drawn without it, and anything that scrolls a target into view now stops short of the bar rather than under it.

Read more