<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>build – ./blog.sh</title>
    <link>https://blogsh.app/tag/build/</link>
    <atom:link href="https://blogsh.app/tag/build/rss.xml" rel="self" type="application/rss+xml" />
    <description>Posts tagged build on Daniel Šnor&#39;s personal site.</description>
    <language>en</language>
    <lastBuildDate>Tue, 08 Sep 2026 16:22:00 +0200</lastBuildDate>
    <item>
  <title>One line of colour</title>
  <link>https://blogsh.app/posts/2026/one-line-of-colour/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/one-line-of-colour/</guid>
  <pubDate>Tue, 08 Sep 2026 16:22:00 +0200</pubDate>
  <description><![CDATA[<p>A reader changed one border colour in <code>site.css</code>, 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.</p>
<h2 id="what-a-build-writes-down">What a build writes down</h2>
<p>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.</p>
<p>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 <code>templates/</code>, <code>lib/</code>, <code>build/</code>, <code>locales/</code> and <code>config/</code>, 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.</p>
<h2 id="why-the-colour-cost-the-archive">Why the colour cost the archive</h2>
<p>Until 1.7 that list also had <code>assets/</code> on it, and <code>assets/</code> is where the stylesheets live.</p>
<p>A page does not contain its stylesheet. It carries a <code>&lt;link&gt;</code> to one, and there is no <code>?v=</code> on that link -- a version baked into the URL would make every page differ on every CSS edit, the same problem wearing a different coat. So the bytes of a rendered page cannot move when <code>site.css</code> moves. The fingerprint was covering a file no page has ever held.</p>
<p>Measured in a copy of the engine, on a test site of 270 posts: a rebuild that changes nothing skips all 378 pages the cache is asked about, and adding a rule to <code>site.css</code> still skips 378. Put <code>assets</code> back into the fingerprint -- the 1.6 behaviour -- and the same edit skips none.</p>
<p>Nothing is given up. Files under <code>assets/</code> are compared with their published copies on every build, by a path that never consulted this record. The edit reaches the site as it always did, and now costs that one file and nothing else.</p>
<h2 id="what-is-still-fingerprinted-and-what-that-costs">What is still fingerprinted, and what that costs</h2>
<p><code>config/</code> stayed on the list, and the palette is one reason it had to.</p>
<p>The colours in <code>config/site.yml</code> are not read out of a stylesheet. They generate one -- <code>assets/css/colors.css</code> is written by the build, not copied from the tree -- and one of them reaches the pages a second way: the light and dark <code>theme-color</code> in the head of every page the layout renders is the palette&#39;s background. Change <code>bg</code> and every one of those pages really is different. Rebuilding them is the record being right.</p>
<p>Change <code>accent</code> and no page moves. It lives only in the generated stylesheet, and the site is rebuilt all the same: the fingerprint is taken over whole trees rather than the keys inside them, so <code>config/site.yml</code> moving throws <code>config/</code> away. On the same test site that edit skipped nothing and rendered all 378. It is the cost this release took off <code>assets/</code>, kept on purpose in a smaller place -- a tree is a coarse thing to hash, and coarse errs towards work. A tree belongs in the fingerprint when a page can hold its contents; linking is not holding.</p>
<h2 id="when-the-record-is-not-believed">When the record is not believed</h2>
<ul><li>The first build after an upgrade. This release changed <code>lib/build_cache.rb</code>, and <code>lib/</code> is in the fingerprint, so the first build on 1.7 renders everything. An engine that worked out which pages a template edit reaches would be wrong eventually, and wrong here means a live site serving stale markup with nothing saying so.</li><li>The facts the build hands to the record by name rather than by hashing a tree: the menu, which content types the archive holds, the address it is published at, and the timezone -- named by the contents of its zoneinfo file, so a system update that rewrites the rules of your zone throws the record away rather than leaving old times on skipped pages.</li><li>Any page whose published file has been deleted, or has moved in size or timestamp since the build wrote it. The record is checked against what is on disk, never only against itself.</li><li>A record that is missing, half-written or unreadable. Deleting <code>.build_cache.json</code> is always safe and costs one slow build.</li><li>A build that died halfway. It writes no record at all, because a record of a site half written would make the next build skip the half that never got written.</li></ul>
<p><code>./blog.sh rebuild --full</code> is the switch for the rest: render everything, compare everything, believe nothing. It is the escape hatch, and it is how the record is tested: a cached build and a full build over the same content must produce byte-identical trees.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>With no version in its URL, getting a changed stylesheet to a reader is the host&#39;s job: it works where files carry an ETag and are revalidated on each load, and a host that caches hard will serve the old one until it expires. And backdating is untouched by this: a post dated 2003 shifts every listing page between the front and where it lands, and those pages really did change.</p>]]></description>
  <category>build</category><category>appearance</category>
</item>
<item>
  <title>Everything else in 1.6</title>
  <link>https://blogsh.app/posts/2026/everything-else-in-1-6/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/everything-else-in-1-6/</guid>
  <pubDate>Sat, 05 Sep 2026 17:06:00 +0200</pubDate>
  <description><![CDATA[<p>Five posts have covered the things 1.6 is about. This one is the rest, in the order an operator meets them.</p>
<h2 id="a-way-out-of-the-trash-and-the-versions">A way out of the trash and the versions</h2>
<p>Both stores had a way back — <code>restore</code>, and the version picker in the post&#39;s properties — and no way out, so both grew for years and nothing said by how much. <code>empty trash</code> and <code>empty versions</code> each print what they are about to remove, in items and in megabytes, and require that count typed back before anything goes. <code>empty versions</code> keeps each post&#39;s newest version, because versions exist to answer &quot;give me back what I just overwrote&quot;. <code>doctor</code> 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 <code>du</code> on the server.</p>
<h2 id="a-photograph-stored-once">A photograph stored once</h2>
<p><code>public.nosync/</code> 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&#39;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.</p>
<h2 id="check-knows-a-type-it-does-not-know"><code>check</code> knows a type it does not know</h2>
<p><code>type: story</code> was stored on the post and read by nobody — no listing, no menu entry, no icon, and not a word about why. <code>check</code> names the eight types it knows now, and the route somebody reaching for a ninth usually wants: a tag named in <code>nav:</code>, which gives a listing with its own pagination, a menu entry and an RSS feed. Asked for in issue #42.</p>
<h2 id="what-three-reviews-found">What three reviews found</h2>
<p>The engine, the new page and the receiver each went through a review before the tag, by someone whose job was to break them, and every finding closed with a test that fails on the old code. Four of them are worth knowing about even if you never meet them.</p>
<p><b>Two posts written at the same instant left one post.</b> Settling a post&#39;s name and writing its file were two steps with a media copy between them, and a copy with photographs takes seconds. Two runs that started together — a delivery from a phone while an import ran, two phones one after the other — were both told the name was free, and the second one&#39;s file replaced the first one&#39;s. Both callers were told it had gone well. The name is taken by creating the file now, which of two runs asking at the same instant only one can do; the other walks on to the next serial, and both posts survive.</p>
<p><b><code>./setup.sh | tee setup.log</code></b><b> echoed the access token in clear text.</b> The prompt hid what was typed only when both streams were terminals. Anyone who set a site up that way should assume the token is in that log and rotate it.</p>
<p><b>Attaching a photo through a symlinked directory in </b><b><code>incoming/</code></b><b> deleted the original.</b> <code>ln -s ~/Pictures incoming/photos</code> is the obvious way to stop copying photographs twice, and the tidy-up after a save compared path text, which follows no symlinks — so every original behind that link was inside its reach.</p>
<p><b>An embed could still smuggle a script past the sanitiser</b>, in three spellings: <code>&lt;svg/onload=…&gt;</code>, <code>javascript&amp;#58;</code> written as an entity, and an <code>&lt;animate&gt;</code> whose values were a script. The pass now walks a tag&#39;s attributes and asks each one what it is, rather than matching shapes. It is the same guard that dresses a tag&#39;s own <code>icon_svg</code>.</p>
<h2 id="eleven-places-where-a-terminal-was-watching">Eleven places where a terminal was watching</h2>
<p>The engine behaved differently in eleven places depending on whether a terminal happened to be attached — a question that flushed only on a tty, a key offered on screen and accepted everywhere, a tag with a letter outside ASCII that crashed under <code>docker exec</code> and cron, where <code>LANG</code> is unset. Nine of them are named in the changelog. All of them matter more now that a post can arrive from a cron job or a phone, where no terminal is ever watching.</p>
<p>Full notes, all of it, in <a href="https://github.com/DanielSnor/blog.sh/blob/main/CHANGELOG.md">CHANGELOG.md</a> under 1.6.</p>]]></description>
  <category>archive</category><category>build</category>
</item>
<item>
  <title>A post handed over as a file</title>
  <link>https://blogsh.app/posts/2026/a-post-handed-over-as-a-file/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-post-handed-over-as-a-file/</guid>
  <pubDate>Thu, 03 Sep 2026 16:42:00 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<pre class="code-block"><code class="language-bash">./blog.sh add post.md</code></pre>
<p>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 <code>incoming/</code>, so the file can arrive by the same upload as the photographs, and it is deleted once the post is written.</p>
<h2 id="where-the-wizard-would-ask-this-refuses">Where the wizard would ask, this refuses</h2>
<p>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 &quot;title:&quot; and &quot;tags:&quot;, exit 0. 1.6 reads the header through them.</p>
<h2 id="json-one-object-nothing-else"><code>--json</code>: one object, nothing else</h2>
<p>With <code>--json</code> the answer is a single object on standard output. A post that was written carries <code>slug</code>, <code>path</code>, <code>state</code>, <code>url</code>, <code>deploy</code> and <code>warnings</code>, every key always present. A refusal carries <code>ok: false</code>, the reason as a code and one sentence.</p>
<p>And a refusal leaves with <b>zero</b>. That is deliberate, and it took a phone to learn it: iOS Shortcuts throws away the output of a remote command that failed, so every refusal a phone could meet came back as a bare status and nothing else — exactly when the reason was the whole point. The status answers the one question the object cannot: whether an answer arrived at all. A non-zero status means the engine is missing or the machine is not set up. Without <code>--json</code> nothing changes: prose on stderr and a non-zero status, as always.</p>
<h2 id="draft-unless-the-file-says-otherwise">Draft, unless the file says otherwise</h2>
<p>The file stops at the draft. <code>publish: yes</code> in its front matter is the one thing a file may ask for that the wizard never could: to go straight out, the date settled, the announcement sent, the site rebuilt, the answer carrying the public address. A post sent from a train has no desk to come back to, so the choice is made when it is sent. Absent, or anything but yes/true/1, is a draft.</p>
<p>At a desk the same two decisions are flags: <code>publish &lt;slug&gt; --yes</code> answers the dialog in advance, and <code>--no-announce</code> puts the page up and sends nothing to Mastodon or Bluesky — <code>toot</code> can still send it by hand afterwards. One thing <code>--yes</code> will not decide for you: a post dated outside the recent window is published but not announced, and the run says so. Announcing is the one step that cannot be taken back.</p>
<h2 id="untrusted"><code>--untrusted</code></h2>
<p>Markdown that arrived over a wire gets one more rule: a picture reference may name only a bare filename. Without it, <code>![](/etc/passwd)</code> reads the file into the post&#39;s media — right at a desk, where whoever typed it has the file anyway, and a door the moment the markdown comes from somewhere else.</p>
<p>None of this adds an API, a token or a listening port. A file is written somewhere the engine can see it, and a command runs. That is the whole integration surface, and it is the same one a person has.</p>]]></description>
  <category>authoring</category><category>build</category>
</item>
<item>
  <title>The time between deciding and the site saying so</title>
  <link>https://blogsh.app/posts/2026/the-time-between-deciding-and-the-site-saying/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-time-between-deciding-and-the-site-saying/</guid>
  <pubDate>Thu, 03 Sep 2026 10:41:00 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<p>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.</p>
<h2 id="what-the-build-remembers-now">What the build remembers now</h2>
<p>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 <code>stat</code> each instead of a full render and a read-back.</p>
<p>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: <i>Pages already up to date, not rebuilt: 10,160</i>. The ratio is what travels; the seconds are one machine&#39;s.</p>
<h2 id="what-it-still-cannot-skip">What it still cannot skip</h2>
<p>A post dated into the early 2000s costs four fifths of a full build, and there is no way around that. A post landing there moves every listing page between the front page and where it lands, and those pages genuinely changed. The cache skips what did not change; it cannot skip what did. Importing an archive is backdating several thousand times over, so an import is a full build, as it always was — which is also why an import should land in one go rather than post by post.</p>
<p>Editing a template, a locale or <code>site.yml</code> throws the whole record away, because any of those can change every page. So does a change of timezone, including the one a system update makes without you touching anything. A stylesheet is the exception worth knowing: pages link it, they do not embed it, so editing one changes the stylesheet and nothing else — the build after it renders the pages once more and finds nothing new to upload but the one file.</p>
<h2 id="a-note-not-an-authority">A note, not an authority</h2>
<p>The record is an optimisation and never the truth about the site. Anything it cannot vouch for is built the old way: a record that is missing, truncated, in a different format, or left by a build that died halfway. A page somebody deleted or damaged by hand in <code>public.nosync/</code> comes back on the next build, because the record is checked against what is actually on disk. And <code>public.nosync/</code> is still swept of anything the build does not produce, cache or no cache.</p>
<p>It lives in <code>.build_cache.json</code> in the installation directory — gitignored, one per machine, always safe to delete. Deleting it costs one slow build and nothing else, which is the whole point of keeping the archive as files: the note about the files is just another file.</p>
<pre class="code-block"><code class="language-bash">./blog.sh rebuild --full        # build every page again, then deploy</code></pre>
<p>Nothing to migrate. The first build after <code>git pull</code> is a full one and writes the record; every build after it reads it.</p>]]></description>
  <category>build</category><category>content</category>
</item>
<item>
  <title>Everything else in 1.5</title>
  <link>https://blogsh.app/posts/2026/everything-else-in-1-5/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/everything-else-in-1-5/</guid>
  <pubDate>Wed, 02 Sep 2026 10:31:00 +0200</pubDate>
  <description><![CDATA[<p>Five posts covered the shape of this release. Here is the rest of it, a paragraph each.</p>
<h2 id="a-copy-button-on-code-blocks">A copy button on code blocks</h2>
<p>Issue #44, from somebody running a blog full of terminal how-tos. It is always there rather than on hover: a phone has no hover, and a button only a mouse can find is one half the readers never get. Only on code blocks — a chat is a <code>&lt;dl&gt;</code>, inline code is a bare <code>&lt;code&gt;</code> — so it hangs on the block <i>type</i>, not on the tag. On an <code>http://</code> install there is no button rather than a dead one, and it copies what the block stores, not what wrapped on screen.</p>
<h2 id="two-things-you-will-see">Two things you will see</h2>
<p>Every listing by content type has an icon now. The other four kinds of listing got theirs in earlier releases, which left these seven looking unfinished; the quotation mark is filled rather than stroked, because outlined at twenty pixels the pair closed up into something that read as the digits 99. And the page is framed on all four sides: a heavy rule has closed it off top and bottom since 1.3, but the sides stayed open, so on a wide screen the text had a beginning and an end and no shape. Off below 700 pixels, where the column already reaches both edges.</p>
<h2 id="where-one-paragraph-ended-and-the-next-began">Where one paragraph ended and the next began</h2>
<p>A search snippet is a post&#39;s text with the paragraphs taken out, so the last words of one and the first of the next meet as one broken sentence. A middle dot stands between them now — not an ellipsis, which already means &quot;cut here&quot; at the end of every snippet, and one mark with two meanings is how a reader learns to trust neither. On the reference archive, 593 of the 1,636 titled posts run past their first paragraph.</p>
<h2 id="the-appearance-button-in-a-window-that-refuses-to-remember">The appearance button, in a window that refuses to remember</h2>
<p>Safari&#39;s private windows — and any profile with site data blocked — throw when <code>localStorage</code> is merely <i>read</i>, not only when written. That threw out of the last line of <code>theme-toggle.js</code>, so the code wiring the button up never ran: no cycling, no symbol, nothing. Both sides are guarded now, and the cycle asks the page which theme it is showing rather than the storage it may not have.</p>
<h2 id="thirty-four-lines-of-your-own-configuration">Thirty-four lines of your own configuration</h2>
<p>Choosing a palette in <code>./style.sh</code> deleted them. The last colour key is the last active key in its section, and the commented-out <code>fonts:</code> block underneath holds a line that uncomments to the same indentation as a colour — so the writer claimed it, and everything below it, as part of what it was rewriting. The file came back without its documented block, and the wizard reported success.</p>
<h2 id="the-other-door-to-a-photograph">The other door to a photograph</h2>
<p><code>./blog.sh add</code> strips the coordinates out of a photograph, exactly as the documentation promises. <code>./blog.sh edit</code> — the path people use far more — had grown a bare copy of the same work, and did not. Two implementations of one promise is one too many.</p>
<h2 id="01-jpg-and-01-jpg">01.JPG and 01.jpg</h2>
<p>An importer keeps the source URL&#39;s extension exactly as it was, case and all, and <code>01.JPG</code> is an ordinary name in a real archive: Posterous served <code>IMG_2669.JPG</code>, and a decade of cameras wrote nothing else. The allocator compared bytes and handed a new picture <code>01.jpg</code> believing it free; the copy asks the <i>volume</i>, and on macOS, or any Windows share, <code>01.JPG</code> answers for <code>01.jpg</code>. So the copy was skipped, the arrival&#39;s bytes were never written anywhere, the post showed the old photograph under both names — and <code>check</code> reported a reassuring &quot;misnamed&quot;. Names are folded on every volume now: one that is free on Linux and taken on macOS is a picture that vanishes when somebody moves their site.</p>
<h2 id="somebody-else-s-stylesheet-inside-your-post">Somebody else&#39;s stylesheet, inside your post</h2>
<p>An imported embed&#39;s HTML was rendered with its scripts, style blocks and stylesheet links intact. The site&#39;s policy already stopped the scripts — but the feed carries the same HTML and has no policy at all, and the style block was never inert anyway: <code>style-src</code> has to carry <code>unsafe-inline</code> for a post&#39;s own colour formatting. Both Instagram embeds in this house&#39;s archive carry <code>body &gt; iframe { min-width: auto !important }</code>, reaching out of the embed to every iframe on the page. Nothing hostile; simply not this site&#39;s to decide.</p>]]></description>
  <category>content</category><category>build</category>
</item>
<item>
  <title>Cut before it is written</title>
  <link>https://blogsh.app/posts/2026/cut-before-it-is-written/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/cut-before-it-is-written/</guid>
  <pubDate>Mon, 31 Aug 2026 16:12:00 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<p>On the real front page that meant fourteen cards carrying between 794 and 2,616 pixels of content and showing 500. Of the page&#39;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.</p>
<p>1.5 cuts the card before it is written. The stylesheet hides nothing.</p>
<p>That page went from 89,268 bytes of HTML to 41,155. Its largest card, 7,463 characters, is now 2,566. And &quot;read more&quot; appears exactly when something did not fit, rather than sitting under a card that was already showing everything it had.</p>
<h2 id="the-budget-is-in-pixels-not-characters">The budget is in pixels, not characters</h2>
<p>This is the part that had to be measured. A character budget is the obvious design and it is wrong, because characters do not measure height. A picture costs no characters and several hundred pixels. Written as a rule of &quot;400 characters&quot;, the cut made 34 % of this archive&#39;s cards <i>taller</i> than the clip it was replacing — one of them 4,928 pixels tall.</p>
<p>So the budget is an estimated height, read off a real card: a 534-pixel content column, a 24-pixel line, about 78 characters to a full line, checked against twelve real paragraphs — eleven of which agreed. The estimate is approximate on purpose. The reader&#39;s column is narrower or wider than the nominal one, and so was the 500-pixel clip it replaces. What matters is that the cut lands between blocks rather than through one.</p>
<h2 id="what-is-never-cut">What is never cut</h2>
<p>A picture. If the first block is a photograph it is kept whatever its height, because on a photo post the photograph <i>is</i> the post, and a card that opens with a cropped image is worse than a card that runs long.</p>
<p>A block made of rows — a hundred lines of shell, a long conversation, a list, a table — is cut to the rows that fit, at a row boundary. A paragraph that runs past the budget is cut on a word, with its formatting cut to match, so a link cannot end up pointing past the text it decorates.</p>
<h2 id="and-an-author-can-overrule-all-of-it">And an author can overrule all of it</h2>
<p>A line reading <code>//--more--//</code> splits a post into what it says about itself and what it actually says. The listing card, the link card and the announcement take the first half; the post&#39;s own page shows everything.</p>
<p>That one was asked for in issue #35 by somebody else running the engine, and their complaint was exact: a machine cut rarely lands on a good sentence, and the author knows where theirs is. A height budget is a decent guess about a card. It is not a claim to know where your post stops being an invitation.</p>]]></description>
  <category>build</category><category>content</category>
</item>
<item>
  <title>Two pages the site built out of itself</title>
  <link>https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/</guid>
  <pubDate>Mon, 31 Aug 2026 10:11:00 +0200</pubDate>
  <description><![CDATA[<p>Until now this engine could show you ten posts at a time and nothing else. <code>/page/128/</code> 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.</p>
<p>1.5 adds two pages that read the archive instead of walking it.</p>
<h2 id="archive-the-shape-of-the-whole-thing">/archive/ — the shape of the whole thing</h2>
<p>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 &quot;has posts / has none&quot; 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. <code>/archive/&lt;year&gt;/</code> is one line per post, by month.</p>
<p>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.</p>
<p>It is an index, not another listing: no excerpts, no pictures, no cards. And it is cheap by construction. Publishing a post rewrites the map and the current year; 2014 has not changed since new year&#39;s eve 2014 and never will, so a deploy that compares content has nothing to upload for it, ever again.</p>
<figure><img src="https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/01.png" width="1280" height="1390" alt="The archive map of sean.cz: a row for each year from 2026 down to 2003, each with its post count and a strip of twelve shaded months" loading="lazy" decoding="async"><figcaption>Twenty-four years on one page: 6,625 posts, from a year that holds 23 of them to a year that holds 641.</figcaption></figure>
<h2 id="tag-every-subject-the-site-has">/tag/ — every subject the site has</h2>
<p>The engine has always built a page per tag and never anything that showed them all. A site&#39;s own list of what it writes about lived in <code>browse</code>, or in the top eight of <code>stats</code>.</p>
<p>The list is sorted by the folded name, and that is not a detail. Ruby sorts strings by bytes, which puts every accented tag after z: on one real archive that is fifty-two of them, and the last six in byte order are <code>školení</code>, <code>školitel</code>, <code>šumava</code>, <code>švihov</code>, <code>želnava</code>, <code>životvkorporátu</code>. A reader looking for one of those between <code>sirky</code> and <code>sport</code> would not find it. <code>stats</code> folds now too — one question should not have two answers.</p>
<p>Each tag is a pill with its count riding inside it as a superscript, so a line that wraps cannot strand a number beside the wrong name. A reader can switch the order to by-count and the choice is remembered. But the page is <i>built</i> alphabetically and the switch is a script on top of it: a reader whose browser never runs that script gets the order the markup already holds, rather than a control that does nothing.</p>
<figure><img src="https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/02.png" width="1280" height="1410" alt="The tag index of sean.cz: tags drawn as pills in wrapped rows under letter headings, each with its post count in superscript" loading="lazy" decoding="async"><figcaption>846 tags under 27 letters — and čarodějnice, česká televize and české dráhy under C rather than after z, which is what the folded sort buys.</figcaption></figure>
<h2 id="nothing-to-configure">Nothing to configure</h2>
<p>Both pages are made out of the posts. There is no list to maintain, no front matter to add, and nothing that can drift out of step with what the site actually holds.</p>
<p>Every tag that has a page appears and no others. A tag carried only by a draft, a page or an unlisted post is drawn under its post as a flat pill with no link — listing it here would point a reader at a 404, and an index that lies about one address is an index you have to check.</p>]]></description>
  <category>archive</category><category>build</category>
</item>
<item>
  <title>The other half of check</title>
  <link>https://blogsh.app/posts/2026/the-other-half-of-check/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-other-half-of-check/</guid>
  <pubDate>Thu, 27 Aug 2026 16:42:00 +0200</pubDate>
  <description><![CDATA[<p><code>./blog.sh check</code> reads the whole archive and says what is wrong: media a post asks for and hasn&#39;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.</p>
<h2 id="repair-walks-it-with-you">--repair walks it with you</h2>
<p><code>check --repair</code> is the checker&#39;s other half: for each finding it offers the one repair that finding allows — an old address written into the target post&#39;s <code>redirect_from</code>, 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.</p>
<p>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.</p>
<h2 id="json-says-everything">--json says everything</h2>
<p>The screen shows twenty of a kind and totals the rest — right for reading, useless for a script. <code>check --json</code> prints every finding, uncapped, each with its kind and its data, so anything that wants to act — a migration script, a cron report — works from the same facts the screen summarizes.</p>
<h2 id="and-check-itself-got-stricter">And check itself got stricter</h2>
<p>The rule behind the new findings is one sentence: an archive check calls sound must be one the build will run on. A file that will not parse, a date nothing can read, text that is not a list of blocks, a slug that is not one path segment — each is now a finding, because each is a build that stops. The one tool whose job is to say otherwise no longer says &quot;sound&quot; about any of them.</p>]]></description>
  <category>archive</category><category>build</category>
</item>
<item>
  <title>Deploy in your own language</title>
  <link>https://blogsh.app/posts/2026/deploy-in-your-own-language/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/deploy-in-your-own-language/</guid>
  <pubDate>Thu, 27 Aug 2026 10:41:00 +0200</pubDate>
  <description><![CDATA[<p>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&#39;s repair instructions, all of it. Not broken, exactly. Just a house where half the rooms answer in a different language.</p>
<h2 id="sixty-sentences-moved-home">Sixty sentences moved home</h2>
<p>1.4 finishes the job: the deploy&#39;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.</p>
<h2 id="what-stays-as-it-arrived">What stays as it arrived</h2>
<p>The diagnostics inside the sentences do not get translated: an HTTP code, a server&#39;s raw answer, an exception&#39;s text. Those are evidence, and evidence is quoted, not paraphrased — the sentence around them is the site&#39;s, the thing inside it is the network&#39;s.</p>
<h2 id="the-plural-trap">The plural trap</h2>
<p>&quot;Built 1 posts&quot; is the kind of bug a translation multiplies — Czech declines counts three ways, German differently again. The counted lines are written label-then-number now (<code>posts: 1</code>), which no language has to decline. Boring is correct, and correct in all three at once.</p>]]></description>
  <category>deploy</category><category>build</category>
</item>
<item>
  <title>Ten smaller things</title>
  <link>https://blogsh.app/posts/2026/ten-smaller-things/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/ten-smaller-things/</guid>
  <pubDate>Mon, 24 Aug 2026 16:12:00 +0200</pubDate>
  <description><![CDATA[<p>The rest of this release is ten additions that each need a paragraph rather than a post.</p>
<h2 id="pages">Pages</h2>
<p><code>page: true</code> gives a post a permanent address at the root — <code>/about/</code> — 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 <code>nav:</code> entry on your behalf; where a page belongs in your menu is your decision.</p>
<h2 id="series">Series</h2>
<p><code>series:</code> groups posts and <code>series_part:</code> 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&#39;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.</p>
<h2 id="unlisted">Unlisted</h2>
<p><code>unlisted: true</code> keeps a published post on its ordinary address and takes it out of every listing, feed and index, <code>noindex</code> included. It is the draft&#39;s hidden address, generalised to a finished post. Deliberately not a password: publishing one no longer announces it either, and <code>--force</code> does not open that door — take the flag off first, so the decision is explicit.</p>
<h2 id="blog-sh-stats"><code>./blog.sh stats</code></h2>
<p>The archive counted from the posts on disk. No build, no network, no <code>env.sh</code>. This site, at the time of writing:</p>
<pre class="code-block"><code>The archive
  43 post(s): 43 published, 0 draft(s), 0 scheduled, 0 page(s)

What they are
  text      ██████████████████ 28 (65 %)
  link      █████              7 (16 %)
  chat      █                  2 (5 %)
  image     █                  2 (5 %)
  audio     █                  1 (3 %)
  document  █                  1 (2 %)
  quote     █                  1 (2 %)
  video     █                  1 (2 %)

Words
  17,048 words in all, 396.5 per post on average, 416 in the middle
  longest: what-rots-quietly (828 words)
  reading the whole archive takes 1.4 hours

Media
  24 file(s), 27.6 MB, across 10 post(s)</code></pre>
<p>Words carry both mean and median, deliberately: here they nearly agree, because these posts are all the same kind of thing. On an archive half made of imported tweets the mean sits far above the middle, and only the pair says so. <code>--json</code> prints the same figures unrounded.</p>
<h2 id="reading-time-and-a-table-of-contents">Reading time and a table of contents</h2>
<p>Reading time on every post page, and on every listing card, because the deciding happens in the listing and reading time is the one thing every post can report. A post too short to time says &quot;under a minute&quot; rather than rounding itself to zero. A contents list appears from four headings up, or whenever a post asks for one with <code>toc:</code>.</p>
<h2 id="a-feed-per-tag">A feed per tag</h2>
<p>For the tags the menu names, with autodiscovery on the tag&#39;s own page. Not for every tag in the archive — on a real archive that is thousands of files, rebuilt and re-diffed on every build, that nobody will ever fetch. A tag in <code>nav:</code> is the site saying <i>this is a subject I publish on</i>, which is the same statement as <i>somebody might want to follow just this</i>. A site still using the derived menu gets no tag feeds at all; that is the answer, not an oversight.</p>
<h2 id="fediverse-creator"><code>fediverse:creator</code></h2>
<p>Mastodon puts the author&#39;s account on a shared link&#39;s preview card. No new configuration key — it is the <code>social:</code> entry already pointing at a profile on the instance the site announces to.</p>
<h2 id="blocking-the-training-crawlers">Blocking the training crawlers</h2>
<p><code>seo.block_ai_crawlers</code> writes a maintained list of them into robots.txt, and <code>seo.robots_extra</code> takes free text for whatever else you want in there. Off by default, and worth saying plainly: robots.txt is a request, not a fence.</p>
<h2 id="a-404-page">A 404 page</h2>
<p>Built by the site rather than left to whatever the host shows: the menu, the search field, <code>noindex</code>, and a signpost under the heading drawn inline in the palette&#39;s own colours, with its one accent arrow pointing the way on. A dead end on my site should still look like my site.</p>
<figure><img src="https://blogsh.app/posts/2026/ten-smaller-things/01.png" width="1280" height="1100" alt="Nothing here, in this site&#39;s own colours" loading="lazy" decoding="async"></figure>
<h2 id="keyboard-phone-and-a-search-that-ranks">Keyboard, phone, and a search that ranks</h2>
<p>Every page has an <code>h1</code> now — listing headings were level two, and the front page had none at all. Focus is visible again, in the accent. <code>prefers-reduced-motion</code> is honoured, so transitions collapse and the back-to-top button jumps instead of scrolling. The lightbox can be opened, walked and closed without a mouse. On a phone the menu closes with Escape or a tap outside, and the search field sits in the bar rather than folded into the menu.</p>
<p>Search itself answers with its best results instead of its most recent ones — a title outranks the text, a whole word outranks the same letters inside a longer one, ties keep their date order — and a search has an address again, so <code>?q=</code> can be sent, bookmarked and returned to.</p>]]></description>
  <category>content</category><category>build</category>
</item>
<item>
  <title>What rots quietly</title>
  <link>https://blogsh.app/posts/2026/what-rots-quietly/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/what-rots-quietly/</guid>
  <pubDate>Thu, 20 Aug 2026 10:41:00 +0200</pubDate>
  <description><![CDATA[<p><code>./blog.sh doctor</code> 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.</p>
<p><code>./blog.sh check</code> walks all of it and says.</p>
<h2 id="what-it-looks-for">What it looks for</h2>
<p>Each finding names a post and a slug — something to go and fix — and carries a line saying what to do about it.</p>
<ul><li><b>Media a post asks for and hasn&#39;t got</b>, a video&#39;s poster included. Usually an import whose download failed. The page renders a hole.</li><li><b>Images stored as 1px or smaller.</b> The build treats those as tracking pixels and drops them <i>together with their caption</i>, so the page loses both without saying so. That one had been happening for a while before anything looked for it.</li><li><b>Internal links pointing at nothing</b> — typically a permalink left over from an import.</li><li><b>Media directories no post owns</b>, left by a deleted post or an import that ran twice. They cost disk, not correctness, so they are warnings.</li><li><b>Files in a post&#39;s own directory that the post no longer names.</b> An import only ever adds, so a source that drops a picture between runs leaves its file stranded.</li><li><b>Two series whose names differ by a character or two</b> — one series with a typo that quietly founded its own. Names differing only in digits are left alone: <code>rok-2025</code> beside <code>rok-2026</code> is two year-series, not a mistake.</li><li><b>One old address claimed by two posts.</b> Whichever renders last wins and the other&#39;s readers land on it.</li></ul>
<h2 id="what-it-said-about-this-site">What it said about this site</h2>
<pre class="code-block"><code>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.</code></pre>
<p>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 <code>01.jpg</code> was written again as <code>02.jpg</code>. 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.</p>
<p>Which is the better argument for the check than the one I had. It doesn&#39;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.</p>
<p>The file is gone now, and running it again says so:</p>
<pre class="code-block"><code>✅ Nothing wrong inside the archive: 43 post(s), with media, links and
   redirects all checking out.

The archive is sound.</code></pre>
<h2 id="it-only-reports">It only reports</h2>
<p>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.</p>
<p>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&#39;s, not the screen&#39;s, so one bad import cannot bury everything else and cannot flatter the totals either.</p>
<h2 id="online-is-a-different-tool-wearing-the-same-name"><code>--online</code> is a different tool wearing the same name</h2>
<p>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.</p>
<p>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 <i>not right now</i> — 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.</p>
<p>Verdicts are remembered for a fortnight, so next week&#39;s run only asks about links it hasn&#39;t seen lately.</p>
<h2 id="the-hardest-part-was-not-finding-things">The hardest part was not finding things</h2>
<p>It was not finding things that aren&#39;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 <code>check</code> reads the content. A checker that exits non-zero every night on a sound archive trains you to ignore the night it means it.</p>
<p>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.</p>]]></description>
  <category>archive</category><category>build</category>
</item>
<item>
  <title>Everything that&#39;s wrong, at once</title>
  <link>https://blogsh.app/posts/2026/everything-thats-wrong-at-once/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/everything-thats-wrong-at-once/</guid>
  <pubDate>Fri, 14 Aug 2026 16:52:00 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<p><code>./blog.sh doctor</code> 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.</p>
<h2 id="it-goes-after-the-silent-failures">It goes after the silent failures</h2>
<p>A loud error at least announces itself. The problems doctor concentrates on are the ones that never say anything:</p>
<ul><li><b>An unknown timezone.</b> Ruby falls back to UTC without a word, and every post is dated wrong from then on.</li><li><b>A banner whose declared size no longer matches the file.</b> Every page jumps as it loads, and nothing anywhere is broken enough to complain.</li><li><b>A widget that can never show anything</b> — configured with the wrong kind of identifier, rendering an empty box forever.</li><li><b>A font named in the config but missing from </b><b><code>assets/fonts/</code></b><b>.</b> The browser silently falls back and the site is simply not the site you designed.</li><li><b>A deploy backend configured half way.</b></li><li><b>The example&#39;s text still sitting where visitors would read it</b> — the about box that still describes a blog engine&#39;s sample site rather than yours.</li></ul>
<p><code>--online</code> additionally asks whether the feeds, the analytics script and the access token still answer. Tokens expire quietly; that is their nature.</p>
<h2 id="it-runs-when-nothing-else-will">It runs when nothing else will</h2>
<p>This is the part that took the most care. Doctor works on configurations too broken for anything else to load — <b>including one whose YAML will not parse</b>, which is exactly the moment you want it.</p>
<p>Related, and shipped alongside: a YAML syntax error in <code>config/site.yml</code> is now a sentence rather than a Psych backtrace from whichever entry point happened to read the file first. It names the line, the column, the three usual causes — a tab where spaces belong, a missing quote, a colon inside an unquoted value — and points at doctor.</p>
<h2 id="errors-and-advice-are-different-things">Errors and advice are different things</h2>
<p>Exit status is non-zero for errors only. Warnings are advice, and advice that fails your build is not advice.</p>
<p>That distinction is why doctor is safe to put in a cron or a pre-deploy check: it will stop you when something is actually wrong, and merely talk when something is merely worth knowing.</p>
<h2 id="why-bother-on-a-tool-this-small">Why bother, on a tool this small</h2>
<p>Because &quot;it doesn&#39;t work and I don&#39;t know why&quot; is where people give up, and the engine&#39;s own failure modes were the ones most likely to produce that sentence. The abort messages were all accurate. Accuracy one problem at a time is still a maze.</p>]]></description>
  <category>build</category>
</item>
<item>
  <title>An old link still knows the way</title>
  <link>https://blogsh.app/posts/2026/an-old-link-still-knows-the-way/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/an-old-link-still-knows-the-way/</guid>
  <pubDate>Thu, 06 Aug 2026 15:06:17 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<p>Renaming a post changes its address. That&#39;s unavoidable — the slug is the URL. What&#39;s avoidable is the old address turning into a 404.</p>
<h2 id="what-renaming-does-now">What renaming does now</h2>
<p>Rename a post — <code>r</code> 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.</p>
<p>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.</p>
<h2 id="the-redirects-belong-to-the-post">The redirects belong to the post</h2>
<p>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&#39;t stop existing while the post was away.</p>
<p>That&#39;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.</p>
<h2 id="the-small-print">The small print</h2>
<p>The redirect list is the post&#39;s own history, so it never contains the post&#39;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.</p>
<p>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.</p>]]></description>
  <category>content</category><category>build</category>
</item>
<item>
  <title>No gems (one asterisk)</title>
  <link>https://blogsh.app/posts/2026/no-gems-one-asterisk/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/no-gems-one-asterisk/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:25 +0000</pubDate>
  <description><![CDATA[<p>./blog.sh needs no gems. Here&#39;s the one asterisk on that sentence, because a claim without its exception isn&#39;t worth much.</p>
<p>The claim first: zero gems, zero npm packages, no Bundler, no lockfile, no <code>node_modules</code> folder quietly gaining weight in the dark. Ruby 2.7 or newer and bash. Clone it and it runs.</p>
<h2 id="the-asterisk-up-front">The asterisk, up front</h2>
<p>Two optional sidebar widgets — Pixelfed and generic RSS — parse XML with <code>rexml</code>. That&#39;s a <i>default gem</i>: 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.</p>
<p>So: if you enable those two widgets on a distro-packaged Ruby and the build complains about <code>rexml</code>, you&#39;re the asterisk. Two ways out — install your distro&#39;s <code>ruby-rexml</code>-ish package, or <code>gem install rexml</code>. Either way it&#39;s one package, once, and only if you use those widgets at all.</p>
<p>That&#39;s the entire dependency story. I&#39;d rather write it out than round it down to zero and wait for someone&#39;s build to disagree.</p>
<h2 id="what-a-real-zero-buys-you">What a real zero buys you</h2>
<p>No network dependencies at build time means the entire site builds offline. On a plane, on a train, on a server that firewalled itself into a corner — <code>ruby build/build_blog.rb</code> neither knows nor cares.</p>
<p>And the output is as boring as the input. Measured on this site, today, fourteen posts:</p>
<div class="table-wrap"><table><thead><tr><th>Measured on this site</th><th style="text-align:right">Value</th></tr></thead><tbody><tr><td>Full build, cold</td><td style="text-align:right">1.9 s</td></tr><tr><td>Full build, warm (only changed files written)</td><td style="text-align:right">0.9 s</td></tr><tr><td>Homepage HTML</td><td style="text-align:right">54 kB</td></tr><tr><td>All CSS</td><td style="text-align:right">24 kB</td></tr><tr><td>All JavaScript</td><td style="text-align:right">23 kB — 555 lines across 8 files</td></tr><tr><td>Requests to third-party domains</td><td style="text-align:right">1*</td></tr></tbody></table></div>
<p>That JavaScript is the complete inventory: theme toggle, lightbox, client-side search, the comments loader, sidebar widgets, mobile nav, scroll-to-top, and a shared utility file. No framework is hiding under any of them.</p>
<p>And yes, a second asterisk snuck into the table, same policy as the first: the one third-party request is my own self-hosted Umami analytics, on another domain of mine, added through the config. The engine itself ships exactly zero — no fonts CDN, no tracker, no embed phoning home. Turn the analytics line off in <code>site.yml</code> and the number is a structural 0.</p>
<h2 id="why-bother">Why bother</h2>
<p>Because every dependency is a small standing appointment with the future. Some Tuesday it needs updating, audits, replacing, or it deprecates the one function you used. A stdlib-only tool skips those appointments — the price is writing a bit more code yourself, and for a blog engine that price turned out to be surprisingly low.</p>
<p>A zero with its exception written down is more useful than a zero without one. If you trust the asterisk, you can trust the rest of the table.</p>]]></description>
  <category>philosophy</category><category>build</category>
</item>
<item>
  <title>A deploy that refuses to nuke your site</title>
  <link>https://blogsh.app/posts/2026/deploy-that-refuses/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/deploy-that-refuses/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:23 +0000</pubDate>
  <description><![CDATA[<dl class="chat"><dt>Petr</dt><dd>I ran --prune after a broken build.</dd><dt>Pavel</dt><dd>So your site is empty.</dd><dt>Petr</dt><dd>No. The deploy refused.</dd><dt>Pavel</dt><dd>…I want that.</dd></dl>
<p>That&#39;s the whole feature: the deploy assumes a large change is a bug until you say otherwise.</p>
<h2 id="how-it-works">How it works</h2>
<p>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.</p>
<p>The interesting part is what happens <i>before</i> 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&#39;s folders out of sight for a minute and asked for a deploy:</p>
<pre class="code-block"><code>❌ Stopped: public.nosync/ has 28 files, but 62 were uploaded last time.
   That&#39;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.</code></pre>
<p>A drop like that almost never means &quot;I deliberately deleted half my blog.&quot; It means a typo in a path, an empty content directory, a build that crashed halfway. Without the guard, <code>--prune</code> would cheerfully delete the rest of your live site and upload the wreckage.</p>
<p>And it&#39;s symmetric: a sharp <i>increase</i> trips the same wire, because a site that suddenly doubled usually means a duplicated tree or a badly merged import, not a very productive afternoon.</p>
<h2 id="the-escape-hatches">The escape hatches</h2>
<ul><li><code>--dry-run</code> shows exactly what would upload, change or be deleted — and touches nothing. Here&#39;s today&#39;s, after editing two posts:</li></ul>
<pre class="code-block"><code>Deploy web -&gt; Surfer: https://blogsh.app  [DRY-RUN]
  62 file(s) total, 10 new/changed, 52 unchanged (skipped)
  [dry] index.html (54990 B)
  [dry] posts/2026/no-gems-one-asterisk/index.html (14852 B)
  [dry] posts/2026/why-not-x/index.html (14584 B)
  [dry] rss.xml (38156 B)
  [dry] search-index.json (29072 B)
  ...</code></pre>
<p>Two posts changed, so ten files upload: the posts, the pages that list them, the feed, the search index. Everything else is a checksum match and stays home.</p>
<ul><li><code>--force</code> is the &quot;yes, I meant it&quot; switch, for the day you really do delete a hundred posts.</li><li><code>--prune</code> is the only destructive flag in the toolbox, and it&#39;s opt-in. (On the <code>git</code> backend every deploy is a snapshot anyway, so pruning is implicit — and reversible.)</li></ul>
<h2 id="one-honest-side-effect">One honest side effect</h2>
<p>Do a bulk import — or publish a fourteen-post series in one evening, as this site just did — and the <i>growth</i> guard trips too. Again, real output, from this site&#39;s launch night:</p>
<pre class="code-block"><code>❌ Stopped: public.nosync/ has 63 files, only 30 were uploaded last time.
   That&#39;s a 110% increase -- looks like a duplicated or broken build.
   Check the build output. If the increase is expected (a bulk import/migration), run again with --force.</code></pre>
<p>That&#39;s not a bug; that&#39;s the guard doing precisely its job on the one occasion you&#39;d forgive it for staying quiet. Check the numbers, feel briefly smug that something checked them at all, and run it again with <code>--force</code>.</p>]]></description>
  <category>deploy</category><category>build</category>
</item>
<item>
  <title>No database. One JSON file per post.</title>
  <link>https://blogsh.app/posts/2026/one-json-file-per-post/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/one-json-file-per-post/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:13 +0000</pubDate>
  <description><![CDATA[<p>Here are three lines of Markdown, and here is what the file on disk looks like after you save them.</p>
<p>What you type:</p>
<pre class="code-block"><code>Posts are **files**. The build is a loop over a folder.

There is no step three.</code></pre>
<p>What lands on disk, one JSON file per post:</p>
<pre class="code-block"><code class="language-json">{
  &quot;slug&quot;: &quot;no-step-three&quot;,
  &quot;title&quot;: &quot;No step three&quot;,
  &quot;date&quot;: &quot;2026-07-31T09:00:00+02:00&quot;,
  &quot;state&quot;: &quot;published&quot;,
  &quot;tags&quot;: [&quot;content&quot;],
  &quot;content&quot;: [
    {
      &quot;type&quot;: &quot;text&quot;,
      &quot;text&quot;: &quot;Posts are files. The build is a loop over a folder.&quot;,
      &quot;formatting&quot;: [
        { &quot;type&quot;: &quot;bold&quot;, &quot;start&quot;: 10, &quot;end&quot;: 15 }
      ]
    },
    {
      &quot;type&quot;: &quot;text&quot;,
      &quot;text&quot;: &quot;There is no step three.&quot;
    }
  ],
  &quot;source&quot;: { &quot;platform&quot;: &quot;manual&quot; }
}</code></pre>
<p><i>(Trimmed for the post — a real file carries a couple more housekeeping fields — but this is the real shape.)</i></p>
<h2 id="look-at-where-the-bold-went">Look at where the bold went</h2>
<p>The text is plain text. The bold is a pair of offsets pointing into it — not <code>&lt;strong&gt;</code> tags baked into a string, not a Markdown blob waiting to be parsed again. That one decision quietly pays for half the engine:</p>
<ul><li>Search indexes the text as-is. No stripping tags, no &quot;why does searching for <i>strong</i> match every post&quot;.</li><li>The build never parses Markdown. Parsing happens once, at save time; the build just renders blocks it already understands.</li><li>Importers from seven platforms all target this same schema — which is why a new import source is an adapter with three methods, not a fork of the parser.</li></ul>
<h2 id="round-trips-with-a-seatbelt">Round trips, with a seatbelt</h2>
<p><code>edit</code> converts the JSON back to Markdown, you edit, it parses again on save. And when a post contains something Markdown can&#39;t express — an embed imported from Bluesky, a link card — the editor warns you before saving would flatten it, and asks. Nothing gets silently thrown away in the round trip.</p>
<h2 id="the-boring-finale">The boring finale</h2>
<p>One post is one file, and I mean that as infrastructure, not poetry:</p>
<ul><li>backup is <code>tar</code></li><li>history is <code>git</code></li><li>migrating away from ./blog.sh is a <code>for</code> loop over a folder of self-describing JSON</li></ul>
<p>And one consequence worth saying out loud: <b>the deployed site is just a build artifact.</b> The working copy — content, media, config — lives on your machine, entirely under your control, no matter where the web part runs or what happens to it. Host disappears, server catches fire, provider triples its prices? Nothing of value was there. You point the deploy at a different backend and the site exists again, byte for byte. The server was never the blog. Your folder is.</p>
<p>An engine should be easiest to leave the same way it was easiest to join. This is that, in one file per post.</p>]]></description>
  <category>content</category><category>build</category>
</item>

  </channel>
</rss>
