<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>./blog.sh</title>
    <link>https://blogsh.app/</link>
    <atom:link href="https://blogsh.app/rss.xml" rel="self" type="application/rss+xml" />
    <description>A minimalist blog engine you run from a terminal: posts are files, the site is a static build, comments live on the Fediverse. No database, no gems, no admin.</description>
    <language>en</language>
    <lastBuildDate>Tue, 06 Oct 2026 14:22:00 -0000</lastBuildDate>
    <item>
  <title>Building an Archive</title>
  <link>https://blogsh.app/posts/2026/steven-d-brewer-has-been-blogging-since-2002/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/steven-d-brewer-has-been-blogging-since-2002/</guid>
  <pubDate>Tue, 06 Oct 2026 14:22:00 -0000</pubDate>
  <description><![CDATA[<p class="link-block">stevendbrewer.com</p>
<p>Steven D. Brewer has been blogging since 2002: PHPWiki, then Drupal, then WordPress. With Drupal 7 at the end of its life he wanted a home for twenty-four years of posts that would need no maintenance, and put them on blog.sh: archive.bierfaristo.com, with the Drupal posts and the Twitter feed already in, and pieces of an old Esperanto-USA blog recovered from the Wayback Machine.</p>
<p>Worth a read if what you&#39;re after is an archive that has to outlive the software it was written in.</p>
<p>#blogsh #archive #drupal</p>]]></description>
  <category>archive</category><category>migration</category>
</item>
<item>
  <title>The page a stranger opens first</title>
  <link>https://blogsh.app/posts/2026/the-page-a-stranger-opens-first/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-page-a-stranger-opens-first/</guid>
  <pubDate>Tue, 06 Oct 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>Saturday, LinuxDays in Prague, a stand run by friends from OSCloud — Mastodon, Pixelfed, PeerTube, Matrix, the services for people who want their data where they can see it. A full day behind one table, and not a single talk seen. This engine was there for the first time in person: on a leaflet, and in conversation whenever the Fediverse came round to &quot;and where would I write?&quot;</p>
<h2 id="four-squares-on-an-a4">Four squares on an A4</h2>
<p>The leaflet is one sheet. Four panels — write, move, deploy, show — each with a terminal excerpt and a QR code. None of the codes lands on a page made for the event. Each one opens a tag page this site already had: <code>/tag/authoring</code>, <code>/tag/migration</code>, <code>/tag/deploy</code>, <code>/tag/appearance</code>. The site&#39;s own archive, sorted by what a stand gets asked, was the landing page. Nothing was built for the day that would be stale the day after.</p>
<figure><img src="https://blogsh.app/posts/2026/the-page-a-stranger-opens-first/01.png" width="1130" height="1600" alt="The leaflet: four panels, four QR codes, one sheet" loading="lazy" decoding="async"></figure>
<h2 id="the-leaflet-was-older-than-the-czech">The leaflet was older than the Czech</h2>
<p>The leaflet went to print in mid-September, when a second language on this site was still a question rather than a setting. By the Saturday the question had been answered: 1.9 had shipped, this site had been bilingual for a fortnight, and every post it had published had Czech words. The leaflet had not been told. Its four codes still point at the English tag pages — a Czech leaflet, handed to Czech visitors, landing every one of them on an English page.</p>
<p>What stood between them and the Czech copy was the small chip in the banner&#39;s corner. Four of them found it. The other fifteen did not go looking, and for this crowd that is the expected number: LinuxDays is IT people and students, who read English all day and have little reason to switch. The mistake was real. Its cost, here, was close to nothing.</p>
<h2 id="what-the-weekend-was-for">What the weekend was for</h2>
<p>Nineteen people from the Czech Republic opened this site over those two days; the weekend before it was three. They stayed two and a half minutes on average and four out of five went past the first page. Sixteen came in at the front door, ten read the introduction, three opened the writing tag, two the migration tag, two appearance — and nobody opened deploy, which is a fair review of a leaflet panel. (The site counted 328 visitors that weekend. Three hundred of them were one crawler working through a search engine&#39;s index from Singapore. The number that matters is the small one.)</p>
<p>It came up several times at the table. Nobody&#39;s archive changed hands there, and nobody was expected to install a blog engine standing up. A stand is where a question gets asked out loud — would it carry my blog, does it write from a phone, where does it run — and the leaflet&#39;s four squares are those questions in the order they tend to come.</p>
<p>A project site at a stand is read on a phone, by someone who did not come for it, in whichever language they happen to think in. This time the site was ready for that and the paper was not — and almost nobody noticed, because almost nobody at LinuxDays needed the Czech. A stand where English is not a given would have noticed, and there the gap would have been exactly one QR code wide.</p>
<a class="file-card" href="https://blogsh.app/posts/2026/the-page-a-stranger-opens-first/02.pdf" download><span class="file-icon">PDF</span><span class="file-meta"><span class="file-label">./blog.sh product sheet for LinuxDays 2026 (PDF)</span><span class="file-sub">PDF · 1.9 MB</span></span><svg class="file-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13"/><path d="M6 12l6 6 6-6"/><path d="M5 21h14"/></svg></a>]]></description>
  <category>philosophy</category><category>i18n</category>
</item>
<item>
  <title>How big it can get</title>
  <link>https://blogsh.app/posts/2026/how-big-it-can-get/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/how-big-it-can-get/</guid>
  <pubDate>Mon, 05 Oct 2026 10:10:00 +0200</pubDate>
  <description><![CDATA[<p>It is a fair thing to ask before you hand twenty years of writing to a tool: at what point does it stop being pleasant to use? Measured here in August, on an archive of 6,639 posts, the honest answer was a shrug: adding one post cost the whole archive — forty-eight seconds from pressing enter to the site being live — because every page that so much as mentioned a post had to be remade, whether it had changed or not.</p>
<p>That&#39;s no longer the honest answer. Since 1.6, the build keeps a record of what went into each page, and remakes only the ones whose record no longer matches. The archive underneath is the same one this was measured on in August, seven posts heavier: 6,646 now, and the engine three releases on, at 1.9.</p>
<h2 id="what-you-wait-for-today">What you wait for today</h2>
<p>Publishing a post dated today, on that same archive: sixteen seconds of build, two of upload — where it was forty-eight. The build isn&#39;t touching pages that didn&#39;t need it any more, so the size of the archive stopped being what the wait was made of.</p>
<h2 id="where-the-size-still-shows-up">Where the size still shows up</h2>
<p>It hasn&#39;t disappeared. It moved. The record a build keeps depends on more than the posts — it hangs off a fingerprint of the engine itself: the templates, the code, the locales, the config. Change any of that, the way an engine upgrade routinely does, and the whole record is discarded at once. The next build after that is exactly the build this archive always had: every page, remade, because there&#39;s no way to know which ones the change actually touched without checking all of them.</p>
<p>Just now, on this same archive — 6,646 posts, 15,777 files — clearing that record and asking for a full rebuild took a minute and a quarter, start to a freshly deployed site. That&#39;s not a regression. It&#39;s the cost the archive has always had for the one operation that still has to look at everything; an ordinary day of writing just doesn&#39;t cause that operation any more. The rate underneath has barely moved either: divide that by the file count and it lands at about four and a half milliseconds a file, where August measured a little over four — the six per cent that 1.9 added to every cold build, because each page now asks which language it speaks, and nothing beyond it.</p>
<h2 id="the-practical-consequence-updated">The practical consequence, updated</h2>
<p>The advice from August still holds, and the gap it closes got bigger, not smaller. Bring an old archive in by importing it, not by writing it in post by post. The Mastodon import wrote 2,088 posts and rebuilt once, at the end — about the same minute and a quarter as above. One post at a time, even at today&#39;s flat sixteen-and-two per post, the same 2,088 posts would still cost something like ten hours of publishing for work a single import finishes before the kettle boils.</p>
<p>The limit was never the archive. It was rebuilding parts of it that already knew they hadn&#39;t changed.</p>
<p><i>The sixteen-and-two figure carries over from the measurement published alongside 1.6. The rebuild-from-a-cleared-cache figure is fresh — two timed runs against this archive on 1.9, seventy-five and seventy-one seconds, so treat &quot;a minute and a quarter&quot; as a first read rather than a settled number.</i></p>]]></description>
  <category>build</category><category>archive</category>
</item>
<item>
  <title>./blog.sh: blogovací engine, kterému stačí terminál</title>
  <link>https://blogsh.app/posts/2026/blog-sh-on-root-cz-the-czech-linux-site-in/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-on-root-cz-the-czech-linux-site-in/</guid>
  <pubDate>Fri, 02 Oct 2026 10:10:00 +0200</pubDate>
  <description><![CDATA[<p class="link-block">root.cz</p>
<p>./blog.sh on <a href="https://root.cz">Root.cz</a>, the Czech Linux site, in Czech and in my own words: why the text and the pictures belong on your own disk, what a build is and where its result can live, how comments ride on the Fediverse, and why twenty-two importers read an export rather than an API. It also says plainly who the engine is not for: more than one author, anyone who wants a web admin, plugins.</p>
<figure><img src="https://blogsh.app/posts/2026/blog-sh-on-root-cz-the-czech-linux-site-in/01.webp" width="1421" height="2000" alt="The article on Root.cz" loading="lazy" decoding="async"></figure>
<p>Worth a read if you read Czech and want the whole case made in one place, including what the engine won&#39;t do.</p>
<p>#blogsh #rootcz #staticsite</p>]]></description>
  <category>philosophy</category>
</item>
<item>
  <title>./blog.sh at LinuxDays 2026</title>
  <link>https://blogsh.app/posts/2026/blog-sh-at-linuxdays-2026/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-at-linuxdays-2026/</guid>
  <pubDate>Thu, 01 Oct 2026 10:41:00 +0200</pubDate>
  <description><![CDATA[<p>LinuxDays 2026 take place in Prague this weekend, on Saturday 3 and Sunday 4 October, at FIT ČVUT in Dejvice, as they do every year. Admission is free.</p>
<p>On Saturday I will be at the stand of my friends from OSCloud all day. If you want to talk about ./blog.sh, ask how this or that works, find out whether it would carry your blog, or tell me what got in your way, this is the chance to do it face to face rather than in issues on GitHub.</p>
<p>Nothing much to prepare. A question is enough — or the blog you have been meaning to move. Everything else we will talk over at LinuxDays 2026.</p>
<p>Programme and directions: <a href="https://www.linuxdays.cz/2026/">linuxdays.cz/2026</a></p>
<p>#blogsh #LinuxDays</p>]]></description>
  
</item>
<item>
  <title>Everything else in 1.9</title>
  <link>https://blogsh.app/posts/2026/everything-else-in-1-9/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/everything-else-in-1-9/</guid>
  <pubDate>Wed, 30 Sep 2026 16:32:00 +0200</pubDate>
  <description><![CDATA[<p>Five posts have covered what 1.9 is about: <a href="/posts/2026/two-languages-one-site/">a post in two languages</a>, <a href="/posts/2026/the-site-speaks-the-language-of-the-page/">the site in the language of the page</a>, <a href="/posts/2026/before-the-first-deploy/">what stands in a deploy target before the first deploy</a>, <a href="/posts/2026/a-host-that-only-speaks-ftp/">a host that only speaks FTP</a> and <a href="/posts/2026/one-shortcut-instead-of-two/">one phone shortcut instead of two</a>. This one is the rest, in the order an operator meets it. Most of it came from two people who set 1.9 up from nothing and upgraded a copy of a 6,600-post site, doing only what the docs said.</p>
<h2 id="setting-up">Setting up</h2>
<p><code>./setup.sh</code> asks which language to speak first, and speaks it from the next screen on — the introduction used to stay in English above a Czech run, because it was said before the choice was made. While the site&#39;s name, short name, description, author and address are still the template&#39;s examples, Enter no longer takes them as answers: an address has to be yours, or <code><a href="http://localhost:8000">http://localhost:8000</a></code> for trying things out on this machine. A deploy directory typed as <code>~/www</code> is written out whole, and a prompt that shows nothing as you type now says what went in — its length and its last two characters, enough to tell an access token from the client key beside it and too little to give either away. A token pasted with a space on either side is trimmed before it is saved.</p>
<h2 id="esc-does-nothing">Esc does nothing</h2>
<p>At &quot;rebuild and deploy the site now?&quot; Esc used to answer yes and deploy; at a post&#39;s crossroads it opened the editor; in a question you type into, it wrote an invisible control character into <code>site.yml</code>. Esc leaves without doing anything now, everywhere — in a typed question it is Esc and then Enter, because the terminal reads a whole line there. And <code>q</code> no longer closes a menu: it was a second Esc in the menus and nowhere else.</p>
<h2 id="the-command-line">The command line</h2>
<p><code>./blog.sh rebuild --help</code> built and deployed the site, and <code>rebuild --force</code> — the rerun the deploy&#39;s own guard asks for — was taken for nothing. <code>rebuild</code> takes <code>--full</code> and <code>--force</code> and refuses anything else by name, and a rebuild whose build failed ends with 1. A mistyped command says it does not exist and names the nearest one, instead of scrolling the whole usage past.</p>
<h2 id="a-draft-s-properties">A draft&#39;s properties</h2>
<p>The properties screen shows where to read a draft, whole even at 80 columns, and <code>[q]</code> puts that address on a phone as a QR code — reading a draft on a phone used to mean opening it in the editor first. <code>[p]</code> asks before it publishes and announces. And the screen stopped promising an announcement nothing would send: on a site that announces nowhere, or has a network configured with its token still empty, it says so.</p>
<h2 id="surfer-7">Surfer 7</h2>
<p>Cloudron&#39;s Surfer 7 signs in with your Cloudron username and an app password, and no longer takes the access token every earlier Surfer did. <code>SURFER_USERNAME</code> and <code>SURFER_PASSWORD</code> go into <code>env.sh</code>; they can go in before the app updates, because an older Surfer is signed in to with the token until the day it is not. A refused sign-in stops the deploy at the first file and says which of the two was refused, instead of printing HTTP 401 once per file.</p>
<h2 id="from-the-phone">From the phone</h2>
<p>Beside the single shortcut, the writing app sends its text from Chrome and Edge: Chromium shares only files whose extension is on its own list, <code>.md</code> is not on it, and the text was refused before any share sheet opened. It goes as <code>.md.txt</code> now, and the receiver puts the <code>.md</code> back. That one was found on an Android phone, by somebody who tested exactly what the browser refuses.</p>
<h2 id="what-doctor-names-now">What doctor names now</h2>
<p>A deploy backend whose program is not installed, rather than letting the first deploy find out after the build — and the rclone snap, which can read only inside your home directory. A quote left open in <code>site.yml</code>, by the line it opens on rather than the line where the parser gave up. A language file still holding the example&#39;s text, and one left behind by a language taken out of <code>site.locales</code>. An option it does not know: <code>doctor --onlien</code> used to run the offline checks and say nothing. It no longer ticks the site&#39;s identity while the author is still the example&#39;s, and no longer says the shipped banner will jump — only the ratio holds its space open.</p>
<h2 id="smaller-things">Smaller things</h2>
<p>A picture captioned in typographic quotes — <code>![alt](01.png „Caption“)</code>, what every phone keyboard writes — pointed at a file nobody has, and vanished from the page. Every embedded player drew a black rectangle on a host that sends a tight <code>Referrer-Policy</code>, which is Surfer&#39;s default. A file size used the English decimal point in every language. A podcast import put the show&#39;s cover over every episode. Ctrl+C left every screen of <code>./blog.sh</code> through a stack trace. <code>publish --json</code> answered an unreadable date with prose and status 1, which a phone cannot tell from a crash.</p>
<h2 id="the-czech-interface">The Czech interface</h2>
<p>The engine&#39;s Czech said &quot;post&quot; in over 300 places, the help and the editor&#39;s hint included, while the sites it builds said &quot;příspěvek&quot;; a build was &quot;postaven&quot;, constructed, where Czech puts one together — &quot;sestavení&quot;. Now it says &quot;příspěvek&quot;, &quot;sestavení&quot; and, for a draft, &quot;koncept&quot;, in the wizard, the command line, the writing app and the Czech cheat sheet. The key is still <code>d</code> for &quot;[d] nechat jako koncept&quot;. A script that reads the Czech output will see the new words.</p>
<p>A cold build costs about 6% more than 1.8&#39;s, the price of every page asking which language it is in. A cached rebuild costs what it did, so an ordinary day of publishing is unchanged.</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.9.</p>]]></description>
  <category>authoring</category><category>deploy</category>
</item>
<item>
  <title>One shortcut instead of two</title>
  <link>https://blogsh.app/posts/2026/one-shortcut-instead-of-two/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/one-shortcut-instead-of-two/</guid>
  <pubDate>Wed, 30 Sep 2026 10:31:00 +0200</pubDate>
  <description><![CDATA[<p>Sending a post from an iPhone took two shortcuts until 1.9: one to take the files from the share sheet, one to open the SSH connection. <a href="/posts/2026/written-on-a-train/">Written on a train</a> walked through both. It is one shortcut now, imported rather than built, and it runs on a Mac as well.</p>
<h2 id="why-there-were-two">Why there were two</h2>
<p>A shortcut started from the share sheet runs in Shortcuts&#39; background runner, whose only screen is a banner. <i>Run Script over SSH</i> needs a screen — the host prompt, the first-run privacy question — and is refused there with &quot;This action could not be run with the current user interface&quot;, before any connection is made. The action that used to hand the run over to the full app is no longer offered. A shortcut started from a URL, on the other hand, always runs in the app. So the second shortcut existed only to be started from a URL.</p>
<h2 id="one-shortcut-run-twice">One shortcut, run twice</h2>
<p>It does not need to be a second shortcut. The same one runs twice, and tells the two runs apart by the one thing that differs between them: whether it was handed anything.</p>
<ul><li><b>From the share sheet</b>, with the post&#39;s files as its input, it packs them into one batch, writes the batch to a file in iCloud Drive and opens a URL that names the shortcut itself.</li><li><b>From that URL</b>, with nothing handed to it, it reads the file back, sends it over SSH, deletes it, and opens the answer on the writing app&#39;s page.</li></ul>
<p>A video on the way through is re-encoded to 720p — an iPhone clip of 32.5 MB came out at 5 MB — and a picture goes as it is.</p>
<h2 id="import-it-then-fill-in-what-is-yours">Import it, then fill in what is yours</h2>
<p>The shortcut is published signed, so anyone can import it: <a href="https://github.com/DanielSnor/blog.sh/raw/main/docs/shortcuts/SendPost.shortcut">SendPost</a>. What it cannot know is yours to fill in:</p>
<ul><li>in <i>Run Script over SSH</i>: the machine you log into, its SSH port, the user, and the path to <code>scripts/receive.sh</code> or the wrapper that reaches it — and a key, whose public half goes on the server;</li><li>in the <i>Text</i> that builds the answer&#39;s address: your blog&#39;s address, <code>base_url</code> in <code>config/site.yml</code>.</li></ul>
<p>It arrives called <code>SendPost</code>, and its last step opens itself by that name. Rename it and that step has to change with it; a name without spaces saves percent-encoding it.</p>
<p>One warning worth having before anything is edited by hand: Shortcuts reconnects a pasted action&#39;s inputs to whatever stands above the place it lands, so an action copied between shortcuts can come back reading from the wrong variable. Importing avoids that entirely.</p>
<h2 id="and-on-a-mac">And on a Mac</h2>
<p>The same shortcut runs from Terminal: <code>shortcuts run SendPost</code>. Nothing on the server changed for it — only the phone&#39;s side got simpler.</p>
<p>The step-by-step, with a screenshot of the whole shortcut, is in <a href="https://github.com/DanielSnor/blog.sh/blob/main/docs/operations.md#a-post-sent-from-the-phone-itself">operations.md</a>.</p>]]></description>
  <category>authoring</category>
</item>
<item>
  <title>A host that only speaks FTP</title>
  <link>https://blogsh.app/posts/2026/a-host-that-only-speaks-ftp/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-host-that-only-speaks-ftp/</guid>
  <pubDate>Tue, 29 Sep 2026 16:22:00 +0200</pubDate>
  <description><![CDATA[<p>A shared hosting plan often comes with a directory, an FTP login and nothing else — no SSH, no rsync, no git. That has been enough for blog.sh all along; the docs just never said so until 1.9. The way there is the rclone backend, because rclone speaks FTP as one of its many remotes.</p>
<h2 id="set-up-the-remote-once">Set up the remote once</h2>
<p><code>rclone config</code> asks its questions once and keeps the answers — the host, the user and the password live in rclone&#39;s own config, never in <code>env.sh</code>. Create a new remote, pick the type <code>ftp</code>, and give it the three things the hosting plan&#39;s welcome e-mail lists. Then two lines in <code>env.sh</code>:</p>
<pre class="code-block"><code class="language-bash">export DEPLOY_BACKEND=rclone
export RCLONE_TARGET=myftp:public_html   # remote name : the directory the host serves</code></pre>
<p>The part after the colon is whatever directory the host serves as your site — <code>public_html</code>, <code>www</code>, <code>htdocs</code>, depending on the host.</p>
<h2 id="why-nothing-else-is-needed">Why nothing else is needed</h2>
<p>FTP cannot tell anybody what a file on the server contains; there is no checksum to ask for. A deploy that had to compare the server&#39;s copy with the build&#39;s would have to download everything first. It never has to: the engine keeps its own record of every file it has sent — the manifest, one per backend — and tells rclone exactly which files to send and which to delete. The first deploy uploads the whole site; every one after that only what changed.</p>
<h2 id="two-things-a-shared-host-does">Two things a shared host does</h2>
<p><b>It may offer FTPS.</b> Plain FTP sends the password in the clear. If the host supports FTPS, turn it on in <code>rclone config</code> (<code>explicit_tls</code>), and nothing in the engine changes.</p>
<p><b>It may allow only a few connections per account.</b> rclone opens several at once by default, and a host with a low limit answers with &quot;too many connections&quot; halfway through the deploy. One more line makes rclone take them one at a time:</p>
<pre class="code-block"><code class="language-bash">export RCLONE_ARGS=&quot;--checkers 1 --transfers 1&quot;</code></pre>
<p>rclone also has <code>--ftp-concurrency</code>, but its own documentation warns that it is very likely to deadlock unless it is set to one more than the two flags above together. The two flags are the simpler way to the same limit.</p>
<h2 id="before-the-first-upload">Before the first upload</h2>
<p><code>./blog.sh doctor</code> checks that rclone is installed — and that it is not the snap, which can read only inside your home directory and fails every deploy of a site kept anywhere else. <code>./blog.sh doctor --online</code> then lists what already stands in <code>public_html</code> that the site does not put there: on a hosting plan that is usually the host&#39;s placeholder page or an old site, and the post <a href="/posts/2026/before-the-first-deploy/">Before the first deploy</a> says what to do about it.</p>]]></description>
  <category>deploy</category>
</item>
<item>
  <title>Before the first deploy</title>
  <link>https://blogsh.app/posts/2026/before-the-first-deploy/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/before-the-first-deploy/</guid>
  <pubDate>Tue, 29 Sep 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>Somebody moves a blog off WordPress onto blog.sh and keeps the same hosting, the same directory, the same address. The build is ready, the first deploy goes through without an error — and the front page is still the old one. Nothing went wrong in the upload. The old site was simply never taken away, and the host prefers it.</p>
<h2 id="a-deploy-only-removes-what-it-wrote">A deploy only removes what it wrote</h2>
<p>A deploy writes the site&#39;s files, and deletes only files an earlier deploy of the same site put there. Everything else in the target stays, on purpose: the engine has no way of knowing whether a file it did not write is a leftover or something you need, and a deploy that guessed wrong once would be a deploy nobody trusts again.</p>
<p>That rule is right, and it has one sharp consequence. Most shared hosting serves <code>index.php</code> before <code>index.html</code>, so an old WordPress front page goes on answering at the root while the new <code>index.html</code> sits right beside it, unseen. An old <code>.htaccess</code> can keep rewriting addresses the new site depends on. The upload succeeded; the reader just never reaches it. (The git backend is the exception — every push replaces the whole branch, so nothing old survives there.)</p>
<h2 id="ask-the-target-first">Ask the target first</h2>
<p><code>doctor --online</code> reads the root of the deploy target and names everything in it the site does not put there:</p>
<pre class="code-block"><code>$ ./blog.sh doctor --online
⚠️  The deploy target (/var/www/myblog) holds 5 item(s) in its root that this site does not put there: .htaccess, google1234abcd.html, index.php, wp-content/, wp-login.php.
   One of them is index.php, which a host serves before index.html -- an old front page can go on hiding the new one. A deploy never deletes what it did not write, so these stay and are served beside the site. Delete what you do not want by hand (FTP, the host&#39;s file manager); and name what is there on purpose under deploy.keep in config/site.yml, and doctor stops listing it (.well-known, where a host keeps its certificate challenge, is never listed).</code></pre>
<p>It works the same way on every backend — Surfer through its API, a local directory by reading it, rsync, rclone and sftp by listing the directory — with one request and a deadline, so a host that does not answer is a warning rather than a hang. Run it once the target is set in <code>env.sh</code> and before anything is uploaded.</p>
<h2 id="clear-it-and-keep-what-is-meant-to-stay">Clear it, and keep what is meant to stay</h2>
<p>The clearing is yours to do, with whatever you used for the old site: an FTP client, the host&#39;s file manager. Leave what the host itself put there. <code>.well-known/</code>, where a host keeps its certificate challenge, is never listed in the first place.</p>
<p>Some things are there on purpose — a verification file a search console asked for, screenshots an old issue still links to. Name those under <code>deploy.keep</code> in <code>config/site.yml</code>:</p>
<pre class="code-block"><code class="language-yaml">deploy:
  keep:
    - google1234abcd.html</code></pre>
<p>and the next run is quiet about them:</p>
<pre class="code-block"><code>✅ The deploy target (/var/www/myblog) answers, and everything in its root is this site&#39;s.</code></pre>
<p>Only doctor reads that list. A deploy never deletes what it did not write either way, so <code>keep</code> is a note to doctor, not a lock on the target.</p>
<h2 id="the-program-behind-the-backend">The program behind the backend</h2>
<p>Four of the six backends hand the upload to a program — rsync, rclone, sftp, git — and a machine without it used to find out from the first deploy, after the whole build had run. <code>doctor</code> asks now:</p>
<pre class="code-block"><code>❌ The rsync backend runs rsync, and rsync is not installed here -- every deploy would fail after the build.
   Install rsync from your system&#39;s packages, then run ./blog.sh doctor again.</code></pre>
<p>rclone has one trap of its own. Installed as a snap, it can read only inside your home directory, so a site kept in <code>/srv</code> fails every deploy on files that are plainly there. <code>doctor</code> names that too, and points at the distribution&#39;s package or rclone.org instead.</p>
<h2 id="and-do-not-point-it-too-early">And do not point it too early</h2>
<p>One more thing worth knowing before the target goes into <code>env.sh</code>: saving a draft builds and deploys the site, because a draft&#39;s preview is a real address on the real site. The first deploy is rarely the one you type by hand. An unedited <code>env.sh</code> deploys nowhere and everything else works — writing, previewing, importing — so the target can wait until the site is ready to be seen, and until <code>doctor --online</code> has had its look at what is already there.</p>]]></description>
  <category>deploy</category>
</item>
<item>
  <title>The site speaks the language of the page</title>
  <link>https://blogsh.app/posts/2026/the-site-speaks-the-language-of-the-page/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-site-speaks-the-language-of-the-page/</guid>
  <pubDate>Mon, 28 Sep 2026 16:12:00 +0200</pubDate>
  <description><![CDATA[<p>Somebody translates their posts, opens the Czech half of the site — and the menu still says <i>Writing</i>, the footer still says <i>Links</i>, and every tag on every pill is the English word. The posts were only ever half of what a reader reads. The rest is what the site says about itself, and in 1.9 that has a language too.</p>
<h2 id="one-file-per-language">One file per language</h2>
<p>What the site says in Czech lives in <code>config/site.cs.yml</code>, beside the config. Start one from <code>config/site.lang.yml.example</code> — copy it, and its comments say the rest. It takes the same keys <code>config/site.yml</code> has for its words, written in the other language, and whatever it leaves out stands as site.yml has it. This site&#39;s own, trimmed:</p>
<pre class="code-block"><code class="language-yaml"># config/site.cs.yml
site:
  description: &quot;Minimalistický blogovací engine, který ovládáš z terminálu …&quot;
nav:
  - { label: &quot;Vše&quot;, url: &quot;/&quot; }
  - { label: &quot;Začni tady&quot;, url: &quot;/posts/2026/blog-sh/&quot; }
  - { label: &quot;Proč&quot;, tag: &quot;philosophy&quot; }
about:
  heading: &quot;O projektu&quot;
footer:
  note_heading: &quot;Licence&quot;
  copyright: &quot;Texty a obrázky © 2026 Daniel Šnor.&quot;
tags:
  philosophy: &quot;filozofie&quot;
  authoring: &quot;psaní&quot;</code></pre>
<p>The title, the description, the banner&#39;s claim and its alt text, the about card, the footer and the menu: those are words, and they translate. The picture in the banner, the author, the address, the colours, the widgets&#39; accounts are facts about the site, the same in every language, and there is no key for them here — translating a fact would make a second site.</p>
<h2 id="some-headings-translate-themselves">Some headings translate themselves</h2>
<p>&quot;About&quot; over the about card, &quot;Links&quot; and &quot;Find me on&quot; over the footer columns, and each widget&#39;s heading are the engine&#39;s own words when site.yml does not write them — and then every language says them in its own words, with nothing to translate. Write one in site.yml only to say something else, and translate that one. The wizards leave the engine&#39;s words out of site.yml for the same reason: frozen into the config, they would stay in one language.</p>
<h2 id="a-tag-is-an-id-with-a-word-per-language">A tag is an ID with a word per language</h2>
<p>A post&#39;s tags are metadata, written once, like its date. So a tag keeps one ID in every language — one page per language at the same address, <code>/cs/tag/philosophy/</code> — and only what a reader sees changes: the word on the pill, over the tag&#39;s page, in its feed, in the tag index and in what the search box finds. <code>tags:</code> gives each tag its word, keyed as the posts write it. A tag with no word shows as written, and a word for a tag no published post carries shows nowhere; <code>check</code> says so, and the build goes on.</p>
<h2 id="the-menu-goes-to-the-same-places">The menu goes to the same places</h2>
<p>The menu and the footer links are written out in full, the way site.yml has them — but every item goes where the same item goes in site.yml, in the same order. Only the label changes. A menu that drifts apart between languages is a second site, and a reader who switches language would lose the item they were reaching for. So the build refuses one that drifts, and says where:</p>
<pre class="code-block"><code>❌ site.cs.yml: nav has to go to the same places as in config/site.yml, in the same order -- only the words change. Item 2: config/site.yml goes to tag:philosophy, site.cs.yml to tag:authoring.</code></pre>
<p>The address in an item is still the one the site&#39;s own language writes. <code>url: /posts/2026/blog-sh/</code> above leads to the Czech copy of that post in the Czech menu, whatever its Czech slug is — the item follows the piece, not the spelling. A <code>tag:</code> item leads to the tag&#39;s Czech listing, and the front page, the search and the archive to their Czech copies.</p>
<h2 id="what-stays-in-one-language">What stays in one language</h2>
<p>The writing app is one copy for the whole site, and it speaks the site&#39;s own language, because that is the language its author writes in. And the rules are enforced rather than hoped for: a key the engine does not read is refused, so is a translation of something site.yml does not have — an about text only one language would show — and a language file that is not valid YAML stops the build, with <code>check</code> and <code>doctor</code> naming the line.</p>
<p>That is the whole of it: posts say their own words, and one file per language says the site&#39;s.</p>]]></description>
  <category>i18n</category><category>appearance</category>
</item>
<item>
  <title>Two languages, one site</title>
  <link>https://blogsh.app/posts/2026/two-languages-one-site/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/two-languages-one-site/</guid>
  <pubDate>Mon, 28 Sep 2026 10:11:00 +0200</pubDate>
  <description><![CDATA[<p>Somebody writes in Czech, and some of the people who would read it don&#39;t read Czech. The usual answer is a second blog — a second archive, a second set of tags, a second feed — and within a year the two have drifted apart: a post fixed in one and not the other, a tag renamed on one side, a reader who switches languages and lands somewhere that no longer matches. blog.sh 1.9 gives a different answer: one site, and the other language beside it.</p>
<h2 id="one-post-a-text-per-language">One post, a text per language</h2>
<p>A post keeps everything that is true of it once: its date, its tags, its series, whether it is pinned, whether it is published. What changes with the language is the words — the title and the text — and those sit side by side inside the same post:</p>
<pre class="code-block"><code class="language-bash">./blog.sh translate a-slug-is-an-address-not-a-name --lang cs</code></pre>
<p>The editor opens with the title and the body and nothing else, because that is all a translation is. The post is still one post: publishing it publishes it in every language, and a tag added to it is added in every language. There is no way for the Czech copy to be a draft while the English one is live, or to be filed under a different tag.</p>
<p>Nothing has to be migrated to start. A post with no translation is simply a post in the site&#39;s own language — which is every post in every archive until the first <code>translate</code>.</p>
<h2 id="where-each-language-lives">Where each language lives</h2>
<p>The site&#39;s own language keeps the root, and every other language gets a root of its own:</p>
<pre class="code-block"><code>/posts/2026/a-slug-is-an-address-not-a-name/
/cs/posts/2026/slug-je-adresa-ne-jmeno/</code></pre>
<p>The root does not move when a second language arrives, so every link anyone has ever made to the site goes on working. The Czech copy gets a Czech address — made from the Czech title the first time it is written, then left alone, the same way the post&#39;s own address is. Pictures, the stylesheet and the writing app exist once and are shared; the sitemap names every language and says which addresses are the same page in another.</p>
<h2 id="when-a-translation-is-missing">When a translation is missing</h2>
<p>A post nobody has translated yet is not hidden from the other language. It stays in the Czech listing, in English, and its link leads to the one copy it has — so the same words never stand at two addresses, and a reader browsing in Czech still sees everything the site has written.</p>
<p>On a site with three languages, what stands in for a missing one is yours to decide. One line in the language&#39;s own file says that German, when it has nothing, borrows the Czech text rather than the English — and the words and the link always come from the same answer, never Czech text under a link to the English page.</p>
<h2 id="switching">Switching</h2>
<p>The switcher is a small chip in the banner&#39;s corner, next to the light/dark button, and it works the same way: a click moves to the next language, and wraps around at the end. Which language you read in is a choice about this visit, like which mode you read in, so it sits where that choice already lives — not in the menu, which it would break in two. From a post it leads to that post in the other language; where the post has no copy there, it leads to that language&#39;s front page instead of to a page that doesn&#39;t exist.</p>
<h2 id="knowing-what-is-written-where">Knowing what is written where</h2>
<p>With two languages, the question an archive raises is no longer only &quot;what is broken&quot; but &quot;what is missing&quot;, and it gets its own report:</p>
<pre class="code-block"><code>$ ./blog.sh check --languages
                                               en  cs
a-badge-that-says-what-a-post-is               ✅   ✅
a-bicycle-nobody-had-to-draw                   ✅   ✅
…
how-big-it-can-get                             ✅   ·
the-lock-that-doesn-t-wait                     ✅   ·
the-parts-that-don-t-rebuild-themselves        ✅   ·</code></pre>
<p>That is this site, today: every published post in both languages, and three drafts that haven&#39;t gone out yet still waiting for their Czech words. A plain <code>check</code> doesn&#39;t count those as problems, because a post that exists in one language isn&#39;t wrong. Publishing is where it matters, so <code>publish</code> stops and names the languages a post has no words in, and offers <code>--allow-partial</code> in the same sentence for the times a post is meant to go out in one language only.</p>
<p>The title over the page, the menu and the footer have words of their own too, and they are translated in a file beside the site&#39;s config — that is a post of its own. What this one is about is the part that decides whether two languages stay one site: a single archive that knows, post by post, which words it has.</p>]]></description>
  <category>i18n</category><category>authoring</category>
</item>
<item>
  <title>./blog.sh 1.9</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-9/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-9/</guid>
  <pubDate>Mon, 28 Sep 2026 08:46:09 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.9 is out — <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.9">release on GitHub</a>.</p>
<p>The release about publishing in more than one language. Name them in <code>site.locales</code>, and a post keeps one date, one set of tags and one series, with a text per language written by <code>./blog.sh translate</code>. The site&#39;s own language keeps the root, every other one gets its own, and one rebuild makes all of them. The banner, menu, footer and tags speak each language too; a post nobody has translated still shows, linking to the one copy that exists. This very site has been running in English and Czech since the release was a draft.</p>
<p>Around it, the first hour: two people installed 1.9 from nothing and upgraded a copy of a 6,600-post site, doing only what the docs said, and everything they tripped over is fixed. Esc never does anything, the setup speaks your language from its first screen and will not take the template&#39;s example as your site&#39;s name, a draft&#39;s properties show where to read it — with a QR code for the phone — and <code>doctor --online</code> now asks the deploy target what stands in its root that the site does not put there.</p>
<p>One thing to do if you deploy to Cloudron&#39;s Surfer: Surfer 7 signs in with your Cloudron username and an app password instead of a token. Put them in <code>SURFER_USERNAME</code> and <code>SURFER_PASSWORD</code>; they can go in before the app updates, and the token carries on until it does. Otherwise nothing to migrate — git pull, rebuild, deploy. The first deploy sends every post page once, because every page now names its language. <a href="https://github.com/DanielSnor/blog.sh/blob/main/CHANGELOG.md">Full notes →</a></p>]]></description>
  <category>release</category>
</item>
<item>
  <title>The parts that don&#39;t rebuild themselves</title>
  <link>https://blogsh.app/posts/2026/the-parts-that-don-t-rebuild-themselves/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-parts-that-don-t-rebuild-themselves/</guid>
  <pubDate>Sun, 27 Sep 2026 11:06:00 +0200</pubDate>
  <description><![CDATA[<p>A laptop dies, or a server gets rebuilt from nothing, and the question that actually matters is narrower than it sounds. Not &quot;how do I back this up&quot; — &quot;which of these files does the engine actually need me to protect, because nothing else here can make them again on its own.&quot;</p>
<p>Most of an installation doesn&#39;t qualify. The engine itself is a git clone; pull it again and it&#39;s the same engine. Everything the build writes comes back with one rebuild, given what&#39;s left.</p>
<p>The instinct is to back up the site itself — the folder a browser actually sees. That&#39;s precisely the one thing this list leaves out. <code>public.nosync</code>, the build&#39;s own output, is exactly as disposable as the engine that made it: one rebuild reproduces it byte for byte, so keeping a copy of it would just be storing the same information twice, under two different names, and trusting the older copy by accident.</p>
<p>What&#39;s left, once the engine and the site it builds are both set aside, is a short list.</p>
<h2 id="what-s-actually-irreplaceable">What&#39;s actually irreplaceable</h2>
<p>The posts themselves, first — the one thing on this whole list that a rebuild, a re-clone or a redeploy cannot get back under any circumstance.</p>
<p>Beside them, and easy to leave out by accident: the version history each edit keeps. It sits next to the posts rather than inside them, so a backup of the posts alone keeps the archive and quietly loses the undo — the difference between a restored site and a restored site that can still answer &quot;what did this say before I broke it.&quot;</p>
<p>The media is its own case since 1.6: a published picture is a hardlink back to the original file, not a second copy, so backing it up costs exactly what storing it once costs — even though the same picture may be referenced from a dozen places in the archive.</p>
<p>Then the smaller, easier-to-forget pieces: <code>config/site.yml</code> for the site&#39;s own identity and integrations, <code>env.sh</code> for the tokens that make it post anywhere at all, and the banner and favicon images — gitignored on purpose, which means a fresh clone hands back the engine&#39;s own defaults instead of yours, silently, if nothing kept a copy of the real ones.</p>
<p>Beyond that, only a couple of conveniences remain: the trash, in case a delete needs undoing later, and a hand-built palette, if the site&#39;s colours were ever anything other than what it shipped with. Neither is precious the way the rest of this list is.</p>
<h2 id="what-restoring-actually-is">What restoring actually is</h2>
<p>A fresh clone, those few paths copied back, one rebuild. That&#39;s the whole procedure — and it&#39;s also, unchanged, what moving an installation to a different machine entirely looks like. A backup and a migration are the same short list, read on two different kinds of bad day.</p>
<p>Everything left off it — the build&#39;s own output, the deploy manifests, the build cache, the markers a scheduled publish leaves for itself between steps — either regenerates for free on the next run or costs exactly one operation to rebuild. None of it is a record of anything; all of it is a record of what the last run already knew how to redo.</p>
<h2 id="not-a-backup-an-export">Not a backup — an export</h2>
<p>It&#39;s worth keeping the two apart, because they answer different questions. A backup is those paths exactly as they already are, meant to come back exact. An export converts — what markdown can&#39;t represent comes out as HTML instead — because it&#39;s built for leaving, not for returning. Restoring from an export works, but restoring from the archive itself is the one that loses nothing on the way. An export preserves the site. A backup preserves the project.</p>]]></description>
  <category>archive</category><category>philosophy</category>
</item>
<item>
  <title>The lock that doesn&#39;t wait</title>
  <link>https://blogsh.app/posts/2026/the-lock-that-doesn-t-wait/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-lock-that-doesn-t-wait/</guid>
  <pubDate>Sat, 26 Sep 2026 11:06:00 +0200</pubDate>
  <description><![CDATA[<p>Twice an hour, on a large enough site, two cron jobs reach for the same folder at almost the same moment: the scheduled publish, building and deploying whatever post came due, and the sidebar refresh, rewriting a few of the same output files with fresh numbers. Most ticks miss each other by minutes. On an archive big enough that a build and a full deploy outlast the interval between them, some don&#39;t.</p>
<p>A deploy walking a tree that another run is still rewriting can serve a page half-written. A run pruning orphans can delete, as an orphan, a page the other run published a second earlier. Neither failure announces itself — the site just ends up quietly wrong.</p>
<h2 id="the-lock-and-what-it-refuses-to-do">The lock, and what it refuses to do</h2>
<p>One file, <code>.blog-sh.lock</code> in the project root, stands between those two runs — and it makes a choice that looks wrong until you see the alternative. A run that finds the lock already held does not wait for it.</p>
<p>A cron tick that loses that race says so and leaves, exit 0. Cron doesn&#39;t need it to wait: the next tick is minutes away regardless, and the alternative — queuing behind the lock — has a worse failure mode than losing a turn. A pile of blocked runs all wake at once the moment the lock frees, and all try to do the same work together.</p>
<p>A run started by hand is held to a different promise. It reports the collision and exits non-zero, because the quiet alternative — succeeding at nothing while looking like it succeeded — would mean <code>./blog.sh</code> told you a deploy happened when it didn&#39;t.</p>
<h2 id="one-operation-not-three">One operation, not three</h2>
<p>Publishing on schedule is publish, rebuild and deploy back to back, and the lock is held for all of it at once rather than reacquired between steps — a rebuild starting the instant a publish ends is exactly the overlap the lock exists to prevent, just with itself as both sides. The build and the deploy it shells out to inherit that same lock instead of asking for their own, which is the difference between one run holding a door open for the parts of itself that come after, and that run deadlocking against its own child.</p>
<h2 id="where-the-choice-doesn-t-even-come-up">Where the choice doesn&#39;t even come up</h2>
<p>Reordering the queue takes the same lock too, and it&#39;s the one place the pattern above breaks — worth naming rather than skating past. It never touches <code>public.nosync</code>; it writes the same post files a scheduled publish would read from, and it holds the lock for the instant of the move alone, the checks and the writes together and nothing longer, never while a prompt sits open waiting for you to pick a slot. There&#39;s no cron-versus-human choice to make here, because there&#39;s no interval to lose in the first place: an operation over before the next tick could possibly land doesn&#39;t need to decide between waiting and bailing. A queue screen left open overnight has never once kept a cron tick out — not because it was built to be polite about it, but because it was never open long enough to be in the way.</p>
<p>Where a filesystem can&#39;t do advisory locks at all — some network mounts — the lock quietly degrades to no lock, which is exactly where every installation stood before this existed.</p>]]></description>
  <category>build</category><category>philosophy</category>
</item>
<item>
  <title>What&#39;s left to get wrong</title>
  <link>https://blogsh.app/posts/2026/what-s-left-to-get-wrong/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/what-s-left-to-get-wrong/</guid>
  <pubDate>Wed, 23 Sep 2026 11:56:49 +0200</pubDate>
  <description><![CDATA[<p>There&#39;s no <code>/admin</code> on this site. No login form to try a password against, no session cookie to steal, no plugin list to check against last month&#39;s CVEs — not because a checklist said to strip them out, but because none of them were ever built in the first place.</p>
<p>The engine&#39;s own architecture notes put the rest of it plainly: the threat model is a static site with no server of its own. Nothing here listens on a port. The goal was never to harden a complex system against everything that could go wrong with it. The goal was to avoid needing one in the first place.</p>
<p>What&#39;s left to get wrong, once that&#39;s true, is narrower than a normal blog&#39;s list — what the pages carry, what the working directory holds, and the one thing that does arrive from outside.</p>
<h2 id="the-one-thing-that-arrives-from-outside">The one thing that arrives from outside</h2>
<p>Writing from a phone means one door does accept bytes from a stranger&#39;s own device: <code>scripts/receive.sh</code>, running as the forced command of an SSH key. It listens on nothing — no socket, no daemon, just a key that can run exactly this script and nothing else.</p>
<p>What comes in is a filename and a stream of bytes.</p>
<p>The filename is held to a short list of things it may not contain: no path inside it, no leading dot, no control character.</p>
<p>The stream is bounded before any of it lands — <code>BLOGSH_MAX_MB</code> cuts the read off first, so nothing gets to overflow anything downstream by simply being large.</p>
<p>And the markdown that follows is parsed <i>confined</i>: an image reference may only be a bare filename, because a path like <code>/etc/passwd</code> typed into <code>![]()</code> would otherwise be read straight into the post and published as if it were a photo.</p>
<p>There&#39;s no archive format anywhere on that path, on purpose. Nothing here ever unzips what a stranger sent, which is also the reason there&#39;s nothing to zip-slip.</p>
<h2 id="what-the-page-itself-can-be-tricked-into-showing">What the page itself can be tricked into showing</h2>
<p>Every piece of text that came off a network — a Fediverse display name, an avatar address, the text of a reply — gets escaped twice, the same way both times: once when the build writes the HTML, again in the DOM a visitor&#39;s own browser builds live for the comments. The one exception is Mastodon&#39;s own status HTML, trusted because it was already sanitised by Mastodon before it reached here — the trust sits in that process, not in the content itself. Whatever a browser is allowed to run beyond that is named in a Content-Security-Policy delivered as a meta tag — because a plain static host may not let a site set a header at all — and a media provider&#39;s origin is admitted only on the pages that actually embed one; an archive page with nothing embedded permits nothing.</p>
<h2 id="limits-stated-rather-than-implied">Limits, stated rather than implied</h2>
<p>A draft&#39;s preview address carries a random token and a <code>noindex</code>, long enough that nobody stumbles onto it and hidden from search besides. That&#39;s obscurity, not a lock. Anyone the link reaches can read the draft — which is the entire point of being able to send someone a link — so it&#39;s worth calling that what it is rather than dressing it up as access control it was never meant to be.</p>
<p><code>env.sh</code> holds the real credentials, stays out of git, and is written <code>600</code>. The one place that quietly slips: the setup wizards leave a <code>.bak</code> of whatever they rewrote, at the same permissions, holding whatever tokens were live before. Worth deleting the moment a rotated token is actually confirmed rotated — a forgotten <code>.bak</code> is a second, unwatched copy of a secret you thought you&#39;d retired.</p>
<p>And underneath both of those sits one thing this model doesn&#39;t defend against at all: the machine actually running the build. A compromised laptop or a compromised server has already won, the same way it would against any tool invoked from that machine&#39;s own shell — there&#39;s no scenario here where the build defends against its own operator, and no attempt to pretend otherwise.</p>
<p>None of this reads like a checklist bolted on after the fact. It reads like what&#39;s left over once a login, a plugin list and an upload endpoint that takes anything were never on the list to begin with.</p>]]></description>
  <category>security</category><category>philosophy</category>
</item>
<item>
  <title>How did I set up blog.sh on my shared hosting</title>
  <link>https://blogsh.app/posts/2026/pavel-moved-a-wordpress-blog-off-a-shared/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/pavel-moved-a-wordpress-blog-off-a-shared/</guid>
  <pubDate>Sat, 19 Sep 2026 17:32:40 +0200</pubDate>
  <description><![CDATA[<p class="link-block">pavelchcepsat.cz</p>
<p>Pavel moved a WordPress blog off a shared host and onto blog.sh, over plain FTP. The write-up goes step by step: the WXR export, the import, rclone, a dry run, the deploy.</p>
<p>Worth a read if your site lives on a host that gives you FTP and nothing else.</p>
<p>#blogsh #wordpress #staticsite</p>]]></description>
  <category>migration</category><category>deploy</category>
</item>
<item>
  <title>Whoever loads last wins</title>
  <link>https://blogsh.app/posts/2026/whoever-loads-last-wins/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/whoever-loads-last-wins/</guid>
  <pubDate>Sat, 19 Sep 2026 09:49:45 +0200</pubDate>
  <description><![CDATA[<p>Two real sites wear a look this engine never shipped. sh.cynicky.blog is dressed as Ghost — a full-width lead card, a dotted frame, tiles instead of a plain list. arch-linux.cz is dressed as a Hugo theme, Blowfish specifically — a compact bar, a profile block, cards laid out like someone else&#39;s homepage entirely. Neither forked the engine. Neither edited a template. Both still take a plain <code>git pull</code>.</p>
<h2 id="one-stylesheet-loaded-last">One stylesheet, loaded last</h2>
<p><code>site.extra_css</code> in <code>config/site.yml</code> names a path, or a list of them, loaded after the engine&#39;s own <code>colors.css</code> and <code>site.css</code>. That&#39;s the entire mechanism — a skin is one file of yours that repaints what&#39;s already on the page, and the engine never writes to it, so an update can never conflict with it.</p>
<p>Local paths only: every page carries <code>style-src &#39;self&#39;</code>, and a stylesheet on another host would just be dropped by the browser with no error anyone would ever see — the build refuses a remote path out loud instead, before that silence has a chance to happen.</p>
<p>Whether that file belongs in git is a separate decision — commit it and a second machine already has your look, exclude it and a public repository never carries a design that wasn&#39;t meant to be public. The engine&#39;s own <code>.gitignore</code> doesn&#39;t decide this for you either way.</p>
<h2 id="the-rule-everything-else-follows-from">The rule everything else follows from</h2>
<p>Loading last means your rules win at equal specificity — including the rules the engine wrote for narrow screens, which is not the part anyone expects to be winning against.</p>
<p>sh.cynicky.blog&#39;s skin floats a table of contents beside the article and gave it a percentage width to match its own measure. On a desktop that looked right. Below 700px, the engine has its own reflow: the contents drop above the text and the width rule becomes <code>auto</code>, because there&#39;s no room beside anything at that size any more. The skin&#39;s percentage didn&#39;t know that and kept applying anyway — loading last means winning always, not just when it&#39;s convenient to. The article came out 155 pixels wide on a 390-pixel phone screen.</p>
<p>The fix wraps the override in the range where it&#39;s actually meant to apply:</p>
<pre class="code-block"><code class="language-css">@media (min-width: 861px) {
  .toc { width: 350px; }
}</code></pre>
<p>Below that width, the skin says nothing, and the engine&#39;s own reflow is left to do the one job it was already doing correctly.</p>
<h2 id="what-the-engine-leans-on-without-saying-so">What the engine leans on without saying so</h2>
<p>Two smaller traps came out of the other skin, arch-linux.cz&#39;s, and both share the same shape: a rule that looks purely decorative turns out to be load-bearing somewhere else.</p>
<p>Unsticking the engine&#39;s nav bar is a reasonable thing for a skin to want. On arch-linux.cz it also made the theme-switch button stop responding to clicks — not because the button broke, but because the bar sat in a CSS grid, where <code>z-index</code> takes effect without needing <code>position</code> at all, and the unstuck bar kept its higher layer sitting invisibly on top of the button. The site&#39;s own admin found it by actually clicking the switch; nothing in a screenshot would ever have shown it.</p>
<p>A second, smaller one: <code>max-width</code> on a flex item with <code>flex-basis: 100%</code> shrinks the item&#39;s hypothetical width rather than capping its contents — which is how an article body once ended up sitting beside its own meta line instead of below it. The cap belongs on the children, not the box around them.</p>
<h2 id="one-page-of-a-listing-not-the-whole-feed">One page of a listing, not the whole feed</h2>
<p>A stylesheet can&#39;t read an address, so the front page and <code>/page/2/</code> used to be indistinguishable to CSS — which mattered the moment either skin wanted a lead card on the first page only. Since 1.3.2 every listing&#39;s <code>&lt;body&gt;</code> says which kind it is:</p>
<pre class="code-block"><code class="language-css">.page-first .post-list-item:first-child { /* the lead card */ }
.page-cont  .archive-note { /* only on the continuations */ }</code></pre>
<p>Two classes rather than one, on purpose — a single mark on the continuations would mean writing the first page&#39;s look unconditionally and then undoing it property by property, and a rule that undoes another rule is the kind a later edit quietly stops undoing.</p>
<figure><img src="https://blogsh.app/posts/2026/whoever-loads-last-wins/01.png" width="1280" height="1100" alt="sh.cynicky.blog" loading="lazy" decoding="async"></figure>
<p>That&#39;s sh.cynicky.blog above — full-width lead card, dotted frame, tiles instead of a plain list, and not one edited engine file behind any of it. Its search stays a field in the engine&#39;s own bar rather than the modal Ghost itself would give it, because rebuilding it as one would mean owning behaviour the engine never promised. Neither skin, this one or arch-linux.cz&#39;s, ever tried for a pixel-perfect copy of what it imitates. Fidelity was never the goal. Belonging to the family was.</p>
<p>A skin stops being the cheap tool the moment it needs markup the engine doesn&#39;t build — at that point editing the template is the honest choice, not a stylesheet fighting to fake one. Short of that line, though, none of this needed a theme system: no hooks, no registry, no hierarchy of overrides to keep straight. One fact carries the whole feature. Your file loads last.</p>
<p>—</p>
<p>P.S. — sh.cynicky.blog isn&#39;t where cynicky.blog itself lives. The real site is still on Ghost, updated most days; sh.cynicky.blog is the same look, kept as a working installation of its own. Its author looked at a real migration and chose to stay where he already was — worth saying plainly, rather than letting a domain name imply otherwise.</p>]]></description>
  <category>appearance</category><category>philosophy</category>
</item>
<item>
  <title>./blog.sh 1.8</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-8/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-8/</guid>
  <pubDate>Mon, 14 Sep 2026 10:10:04 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.8 is out — <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.8">release on GitHub</a>.</p>
<p>A release about what happens when something goes wrong. Every page, feed and index is now written to a temporary file and renamed into place, so a save interrupted by a full volume or a build stopped mid-write no longer leaves a page at half its length, or a post at 0 bytes with the old text gone. A cold build costs about a fifth more for it; a cached rebuild does not, so an ordinary day of publishing costs exactly what it did.</p>
<p>Arriving twice stopped meaning happening twice: a post sent from the phone twice, or one receipt delivered in two copies at once, is one post now, and re-importing this engine&#39;s own export no longer turns 1200 posts into 2400. And an address is no longer trusted just because it looks like one — a <code>../</code> in a slug, a media filename or a redirect target stays inside the site, a local deploy stays inside its directory, and a feed&#39;s redirect cannot send a fetch to <code>localhost</code>.</p>
<p>Before the tag, five narrow review passes went through the write paths, the build&#39;s output, import and export, <code>check</code> and the series; everything they found is fixed and pinned by a test that fails on the old code. Most of it nobody will ever meet. What you might: <code>check</code> asks what the build asks, so a page slugged <code>index.html</code> no longer stops every build while <code>check</code> calls the archive sound; an export no longer stops at the first broken post, and comes home with its empty paragraphs and embeds intact; a series of twenty parts or more opens at part one; and a part numbered <code>08</code> no longer loses its number to octal.</p>
<p>Nothing to migrate — <code>git pull</code>, rebuild, deploy. The first build renders every page once; on my own 6,600-post archive the deploy after it uploaded a single file, the Markdown cheat sheet. <a href="https://github.com/DanielSnor/blog.sh/blob/main/CHANGELOG.md">Full notes →</a></p>]]></description>
  <category>release</category>
</item>
<item>
  <title>The build doesn&#39;t know where it&#39;s going</title>
  <link>https://blogsh.app/posts/2026/the-build-doesn-t-know-where-it-s-going/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-build-doesn-t-know-where-it-s-going/</guid>
  <pubDate>Sat, 12 Sep 2026 19:26:30 +0200</pubDate>
  <description><![CDATA[<p>Somebody builds a whole site on a laptop, imports a decade of old posts into it, previews every page — and none of it has gone anywhere yet. Nothing in <code>env.sh</code> says where, and nothing else is waiting on that: the build runs, the archive checks out, every page renders at <code>localhost</code>. The build doesn&#39;t know where the site is supposed to end up, and it doesn&#39;t need to.</p>
<p>The same build can start on a laptop, move to a rented VPS a year later, and land on a Cloudron instance after that. Nothing in the content pipeline changes between any of those moves. What changes is the answer to one question — where should the files go — and six things can answer it.</p>
<h2 id="what-decides-it">What decides it</h2>
<p><code>DEPLOY_BACKEND</code> in <code>env.sh</code> names the target; the values under it depend on which one. Everything past that line is identical across all six. Each backend keeps its own manifest — the SHA-256, size and modification time of every file it has already uploaded — so a deploy only ever sends what changed since the last one. <code>deploy-web.sh --dry-run</code> reads that manifest without touching anything.</p>
<p>Here&#39;s what that prints, run just now against this site&#39;s own backend:</p>
<pre class="code-block"><code>$ ./scripts/deploy-web.sh --dry-run
== deploy-web.sh ==
Mode: preview (dry-run) -- nothing is actually uploaded.

Deploy web -&gt; Surfer: https://blogsh.app  [DRY-RUN]
  Files selected: 194, 33.3 MB in the build, new or changed: 0, unchanged (skipped): 194</code></pre>
<p>Nothing had changed since the last real deploy, so the manifest says so and stops there — the same sentence a <code>git</code> or <code>rclone</code> target would print, with a different line above it saying which one answered.</p>
<p>The manifest is per backend, though, so point the same build at a different target for the first time and that first deploy uploads everything — moving is one full upload, not a redo of anything the build did.</p>
<h2 id="the-strictest-one-sets-the-rule">The strictest one sets the rule</h2>
<p>A single file over 100 MB is refused at save time and again at deploy time, and the limit is the same number on all six backends — not because every target shares that ceiling, but because the engine picks the strictest one and holds everyone to it. A site built to run on <code>rsync</code> stays deployable on <code>sftp</code> without anyone finding out the hard way which backend was the forgiving one.</p>
<p>That&#39;s the tell that this isn&#39;t six uploaders sitting next to each other: it&#39;s one deploy model — one manifest shape, one safety guard, one file-size limit — with six ways to reach the far end. The backends differ in how the bytes travel; what&#39;s allowed to travel isn&#39;t theirs to decide.</p>
<h2 id="the-six">The six</h2>
<p><b>surfer</b> is the default — set <code>SURFER_URL</code>, your Cloudron username in <code>SURFER_USERNAME</code> and an app password in <code>SURFER_PASSWORD</code>, and nothing else needs saying. It&#39;s Cloudron Surfer, the host this project itself runs on.</p>
<p><b>local</b> writes straight into a directory, for a docroot behind your own nginx or Caddy. HTTPS is the web server&#39;s job either way; the engine&#39;s CSP arrives as a meta tag, so there&#39;s nothing to configure on that front even without one.</p>
<p><b>rsync</b> goes to any SSH host with rsync installed — the most universal of the six, since that describes most VPSes and a good share of shared hosting too.</p>
<p><b>git</b> pushes to GitHub, GitLab or Codeberg Pages, force-pushing the whole build as a single commit on every deploy. A custom domain needs <code>GIT_PAGES_CNAME</code> set, because the host keeps that address in a file living inside the very branch the snapshot overwrites.</p>
<p><b>rclone</b> reaches S3, R2, B2, WebDAV and whatever else <code>rclone config</code> knows how to talk to, with the bucket&#39;s own credentials kept in rclone&#39;s config rather than in <code>env.sh</code>.</p>
<p><b>sftp</b> is for the hosts with neither rsync nor git: plain SSH, batch-mode <code>sftp</code>, one connection per deploy. Key auth has to be set up first — a batch job has no terminal to answer a password prompt, and the connection fails rather than waiting for one that isn&#39;t coming.</p>
<p>Which of those six a site runs on is a single line in <code>env.sh</code>, picked once and rarely revisited — because the harder half of the problem was never the backend. It was making sure it wouldn&#39;t matter which one got picked.</p>]]></description>
  <category>deploy</category><category>philosophy</category>
</item>
<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>The last thing a recorder writes</title>
  <link>https://blogsh.app/posts/2026/the-last-thing-a-recorder-writes/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-last-thing-a-recorder-writes/</guid>
  <pubDate>Tue, 08 Sep 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<p>It had been waiting for the index.</p>
<h2 id="why-it-sits-at-the-end">Why it sits at the end</h2>
<p>An MP4 is a handful of boxes laid end to end. One of them, <code>mdat</code>, is the picture and the sound. Another, <code>moov</code>, is the index: which frame is at which byte, how long the thing runs, what codec to hand it to. A player needs <code>moov</code> before it can show a single frame.</p>
<figure><img src="https://blogsh.app/posts/2026/the-last-thing-a-recorder-writes/01.png" width="860" height="400" alt="Two diagrams of an mp4: the index at the end as recorded, and at the front after the repack" loading="lazy" decoding="async"><figcaption>The same bytes, moved. Nothing is re-encoded.</figcaption></figure>
<p>A recorder cannot write <code>moov</code> until the recording stops: until then it does not know what will be in it. It writes the picture first and the index last. Every phone does this, and so does the share sheet that repacks what the phone recorded. The file is correct and it plays. It only makes the reader download all of it to see the first second.</p>
<p>Moving that index to the front is one flag of one program. What was missing was anything in the engine that knew the question existed.</p>
<h2 id="reading-where-it-sits">Reading where it sits</h2>
<p><code>lib/video_probe.rb</code> walks the file&#39;s top-level boxes, reading the four-byte length and four-byte name of each and seeking past the body. No ffprobe, no gem -- the same box walk that already reads a video&#39;s frame size. If <code>moov</code> comes before <code>mdat</code>, the file starts fast. If it comes after, it does not.</p>
<p>A counter around <code>File#read</code>, on a forty-megabyte file with its index at the end: twenty-four bytes. Three box headers. The forty megabytes between them are never touched.</p>
<p>It answers <code>nil</code>, not <code>false</code>, when the question does not arise -- no <code>moov</code>, no <code>mdat</code>, nothing it can parse. &quot;I cannot tell&quot; and &quot;badly ordered&quot; are different answers, and running them together would report a fault about every unreadable file. The walk also refuses to descend: an <code>mdat</code> is full of bytes that can spell anything, including a box header spelling <code>moov</code>.</p>
<h2 id="moving-it-on-the-way-in">Moving it on the way in</h2>
<p><code>media: remux_video: true</code> in <code>config/site.yml</code>, and the engine repacks a video as it is attached:</p>
<pre class="code-block"><code>ffmpeg -nostdin -loglevel error -y -i IN -c copy -movflags +faststart OUT</code></pre>
<p><code>-c copy</code> is the whole of it: the picture and the sound are copied across as bytes, nothing re-encoded, nothing decided about quality. <code>+faststart</code> writes the index first. A QuickTime <code>.mov</code> comes out <code>.mp4</code> while it is there, because some browsers decline the container whatever is inside it. An <code>.mp4</code> that gets repacked comes out <code>01-web.mp4</code> rather than <code>01.mp4</code>: the same name is offered when an author runs ffmpeg by hand, and ffmpeg refuses its own input.</p>
<p>A repack counts as done only if ffmpeg left cleanly and left a file with bytes in it: a tool that dies halfway leaves a truncated file and can still exit happy, so the status alone is not an answer.</p>
<p>This was exercised against thirty real videos out of a working archive with ffmpeg 7.1. Three carried the index at the end. All thirty came out fast-start, and every one kept its duration, which is what proves the copy is a copy.</p>
<h2 id="the-ones-already-in-the-archive">The ones already in the archive</h2>
<p><code>check</code> opens the videos an archive already holds and names the ones with the index at the end, by post and by file:</p>
<pre class="code-block"><code>klip: the video 01.mp4 carries its index at the end of the file.</code></pre>
<p>Beside each it prints the repack command -- in outline, with <code>FILE</code> where the name goes -- and the setting that does it on future saves. It is a warning, not an error: <code>check</code> leaves with zero and counts it worth a look rather than a problem. The file is not broken. It is slow, and slow is the author&#39;s to decide about.</p>
<p>On a save the same sentence appears only where the two older notices have nothing to say. An HEVC clip and a <code>.mov</code> already come with a command that moves the index as a side effect, and a third command for one file is a third thing to weigh.</p>
<h2 id="what-it-refuses-to-do">What it refuses to do</h2>
<p>It needs ffmpeg and does nothing without it. With the setting on and no ffmpeg on the machine, the save says the repack was skipped and stores the file as it arrived.</p>
<p>A repack that fails is not a refusal either. The post is saved, the video goes in as it came, and the author gets the sentence they would have had with the setting off. That is the difference from the HEIC conversion, which does refuse: a HEIC photo displays in Safari and nowhere else, while a video in the wrong wrapper still plays for nearly everybody.</p>
<p><code>check</code> fixes nothing. It prints the command, and running it is a person&#39;s job.</p>
<p>It is off by default, like everything else here that shells out to a tool the engine does not ship.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>Installing ffmpeg once. On save, the time it takes to copy one file: the cost follows the size of the video, not the length of an encode that never happens. And nothing on the archive already on disk until the commands <code>check</code> hands over are typed, one video at a time.</p>]]></description>
  <category>authoring</category><category>content</category>
</item>
<item>
  <title>A bicycle nobody had to draw</title>
  <link>https://blogsh.app/posts/2026/a-bicycle-nobody-had-to-draw/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-bicycle-nobody-had-to-draw/</guid>
  <pubDate>Mon, 07 Sep 2026 16:12:00 +0200</pubDate>
  <description><![CDATA[<p>The example configuration this engine ships carries a bicycle: a tag called <code>kolo</code>, and under it an <code>&lt;svg&gt;</code> written out by hand. It is there because there was nothing else to put there. <code>tag_icons</code> 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 &quot;audio&quot;, or somebody could spend an evening with a coordinate grid.</p>
<p>1.7 puts fifty-five drawings in between, and gives each of them a name.</p>
<h2 id="fifty-five-nouns">Fifty-five nouns</h2>
<p>Counted in <code>lib/icons.rb</code>: 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.</p>
<figure><img src="https://blogsh.app/posts/2026/a-bicycle-nobody-had-to-draw/01.png" width="1000" height="660" alt="A sheet of the fifty-five drawings, each with its name" loading="lazy" decoding="async"><figcaption>The whole set, by name. Drawn on the same grid and stroke as the eight the engine has always shipped.</figcaption></figure>
<ul><li>getting about — <code>bike</code> <code>car</code> <code>train</code> <code>plane</code> <code>boat</code> <code>walk</code></li><li>places and weather — <code>map</code> <code>pin</code> <code>mountain</code> <code>tree</code> <code>sun</code> <code>cloud</code> <code>rain</code> <code>snow</code></li><li>a day — <code>coffee</code> <code>beer</code> <code>food</code> <code>wine</code> <code>clock</code> <code>calendar</code> <code>home</code> <code>heart</code> <code>star</code> <code>gift</code></li><li>making things — <code>pen</code> <code>brush</code> <code>camera</code> <code>film</code> <code>mic</code> <code>music</code> <code>book</code> <code>tools</code> <code>hammer</code></li><li>machines — <code>laptop</code> <code>phone</code> <code>code</code> <code>terminal</code> <code>server</code> <code>bug</code> <code>lock</code> <code>key</code></li><li>living things — <code>paw</code> <code>bird</code> <code>leaf</code> <code>flower</code></li><li>ideas — <code>bulb</code> <code>flag</code> <code>globe</code> <code>eye</code> <code>chart</code> <code>target</code> <code>rocket</code> <code>mail</code> <code>briefcase</code> <code>box</code></li></ul>
<p>Plain English, and the word somebody reaches for first: <code>bike</code>, not <code>bicycle</code>. A footer link under <code>social:</code> can wear one too, where the network marks have nothing to offer: <code>globe</code> for somebody&#39;s other site.</p>
<h2 id="why-they-sit-beside-the-eight">Why they sit beside the eight</h2>
<p>The same 24-unit grid, the same two-unit stroke, the same <code>currentColor</code> — the part that lets one drawing follow a light theme and a dark one without a second copy. All fifty-five carry round ends and round joins, because they are pictures of things rather than diagrams and a mitred corner on a two-unit stroke reads as a spike at twenty pixels. <code>doctor</code> holds a hand-written <code>icon_svg</code> to that same grid.</p>
<h2 id="where-it-turns-up">Where it turns up</h2>
<p>Two places. The heading of the tag&#39;s own <code>/tag/&lt;name&gt;/</code> listing, and the date badge of every post carrying that tag — where it replaces the content type&#39;s icon rather than joining it. A badge is a small tile with a date and one glyph; the only thing that ever adds a second is a pinned post, and a listing stacks ten badges under each other.</p>
<p>A tag is matched by its address rather than its spelling. <code>Sci Fi</code> in <code>site.yml</code> reaches posts tagged <code>sci-fi</code> and posts tagged <code>sci_fi</code>, because those are one page and therefore one tag.</p>
<h2 id="the-order-in-the-list-is-the-priority">The order in the list is the priority</h2>
<p><code>tag_icons</code> is a list, and the order in it decides. Most posts carry more than one tag, and the tag a post was given first is usually whatever an importer put there rather than a subject — so the first entry in the list that a post has is the one it wears, settled once instead of post by post. A post tagged <code>sci-fi, coffee</code> wears coffee if coffee stands higher, whatever order the post wrote them in.</p>
<p>Written as a mapping — <code>kolo: bike</code> indented underneath, the shape half of <code>site.yml</code> is in — it draws nothing, and it always did: until now with no warning from the build, and <code>doctor</code> calling the file sound. Both name the key now, and <code>doctor</code> counts it a problem and exits 1.</p>
<h2 id="what-it-refuses-to-do">What it refuses to do</h2>
<p>It is not a folder to drop drawings into. Everything under <code>assets/</code> is copied onto the site wholesale, so a folder of fifty would publish fifty files to serve a site that uses two — and an icon is written into the page rather than fetched, so nothing would ever ask for them. The set is a Ruby hash for that reason.</p>
<p>The eight content names cannot grow without a content type behind them. And an <code>icon_svg</code> that is not a drawing at all — a filename, an address, an emoji — is refused rather than printed where the glyph goes; the tag falls back to what it would have had with no entry.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>A name the engine does not have draws nothing, and the build does not mention it: configure <code>bicycle</code> instead of <code>bike</code> and the site builds, exits zero, and every post that should have carried a bicycle keeps its content type&#39;s icon. <code>doctor</code> is what says so: it names the tag, the name it asked for, and prints all sixty-three, eight to a row — then exits zero itself, because a tag without a picture is not a broken site.</p>
<p>And the drawing goes into the page. The middling one is a shade over 250 bytes of markup, written out again for every badge that wears it: no request for it, and no way to have it once. The whole set comes to about fifteen kilobytes, and none of it reaches the site — only the drawings actually used.</p>
<p>Most of the drawings in that file are this project&#39;s own. Several are the shape any icon set converges on for an obvious thing — the pen, the spanner, the speech bubble on a chat post — and those are the same paths as Feather Icons, MIT licensed, copyright Cole Bemis. <code>NOTICE</code> says so and carries the licence, which is what a repository owes the people it borrowed from.</p>]]></description>
  <category>appearance</category><category>content</category>
</item>
<item>
  <title>The address a post is about</title>
  <link>https://blogsh.app/posts/2026/the-address-a-post-is-about/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-address-a-post-is-about/</guid>
  <pubDate>Mon, 07 Sep 2026 10:11:00 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<p>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 <code>edit</code> on a link post offered to drop the card, and <code>add &lt;file&gt;</code> could not make one at all. This project&#39;s own release posts stopped being link posts after 1.3 for that reason. The tool had changed; the intent had not.</p>
<h2 id="three-lines-in-the-header">Three lines in the header</h2>
<pre class="code-block"><code>---
tags: release
link: https://example.org/somebody-elses-article
link_title: The article by somebody else
link_description: What its own page says it is about.
---

Two things in this are wrong and one is worth stealing.</code></pre>
<figure><img src="https://blogsh.app/posts/2026/the-address-a-post-is-about/01.png" width="1000" height="900" alt="A post whose card carries a title, a description and the address it is about" loading="lazy" decoding="async"><figcaption>A post that is about an address: the card sits above the text, and the post borrows its title.</figcaption></figure>
<p><code>link:</code> is the address the post is about. <code>link_title:</code> and <code>link_description:</code> are the words on the card. The card is drawn above the text rather than inside it: in the saved post it is the first block, ahead of the first sentence you wrote, and on the page it comes out as the paragraph before yours.</p>
<p>It is a header key rather than a line in the body because the card belongs to the post and not to a paragraph of it -- and because a paragraph that is only a link already means something else.</p>
<h2 id="what-the-post-is-then-called">What the post is then called</h2>
<p>A post with no <code>title:</code> of its own is named by the card. The heading becomes the card&#39;s title, and that heading is a link: it goes where the card goes, on the post&#39;s own page and in every listing. In a listing the date badge beside it is the way to the post itself. Below it the card does not say the same thing twice: the title has been lifted, so what is left is the description.</p>
<p>Two things stay yours. The post&#39;s address is still cut from your own opening words, not from the borrowed title. And the announcement, when the post goes out, carries the post&#39;s address rather than the other page&#39;s.</p>
<p>The type is derived, so nothing has to say <code>type: link</code>: a post that is a card and the words under it is filed as a link post and turns up under the site&#39;s own listing of them.</p>
<p>Give the post a <code>title:</code> and nothing is borrowed. Your heading stands, and the card renders in full underneath it -- its title, linked, with the description under it.</p>
<h2 id="a-paragraph-that-is-only-a-link">A paragraph that is only a link</h2>
<p>It stays an ordinary link in ordinary text. A bare address on its own line is turned into a link and nothing more; a markdown link alone in a paragraph is a paragraph with a link in it.</p>
<p>That is a refusal, not an omission. If a lone link became a card, the two would be indistinguishable, and there would be no way left to write the first one -- the sentence that points at something on the way past.</p>
<h2 id="what-a-card-s-address-may-be">What a card&#39;s address may be</h2>
<p>A whole <code>http://</code> or <code>https://</code> address, or one rooted at this site -- <code>/posts/2026/some-post/</code> -- for a card about another post here. The second shape is not a convenience. <code>check --repair</code> writes it when it straightens a relative link left behind by an import, and a value the engine writes has to be a value it reads back.</p>
<p>Everything else is refused before anything is written: a bare filename, a scheme that runs, and the two spellings that read as this site and resolve elsewhere, <code>//host/path</code> and <code>/\host/path</code>. A card is the one link on a post that may carry no words of its own to be judged by. <code>link_title:</code> with no <code>link:</code> behind it is refused too, rather than guessed at -- words about a card with no card.</p>
<p><code>check</code> verifies a card rooted at this site against the archive: point one at a post that is not there and it says so by name and leaves non-zero. An address out on the web is only reached with <code>--online</code>.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>The card is header text, which means a desk: <code>add &lt;file&gt;</code>, or the editor that <code>add</code> and <code>edit</code> open, whose guide lists the key. The page at <code>/write/</code> has no field for it -- a post written on a phone is written without a card.</p>
<p>Nothing goes and reads the other page. The title and the description are the ones you type -- two lines of copying, and they are plain text: markdown inside them stays on the page as the characters you wrote.</p>
<p>And the card goes the way a header line goes. Take the <code>link:</code> line out on a save and the card leaves the post, with nothing said: the save&#39;s loss guard counts the blocks under the header, and the card is no longer one of them. A card further down a post -- one an importer left there, which markdown still cannot write -- is guarded as it always was. The one in the header is a line to put back, and no warning that it is gone.</p>]]></description>
  <category>authoring</category><category>content</category>
</item>
<item>
  <title>What a post is, not what it says</title>
  <link>https://blogsh.app/posts/2026/what-a-post-is-not-what-it-says/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/what-a-post-is-not-what-it-says/</guid>
  <pubDate>Sun, 06 Sep 2026 17:17:00 +0200</pubDate>
  <description><![CDATA[<p>Five parts of a series written over a spring, and the third went out without its <code>series:</code> 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&#39;s page called itself part 3 of 4, standing in the slot the missing post should have had. The fix was one word.</p>
<p>Getting to that word meant opening the article.</p>
<p>That is what <code>edit</code> 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.</p>
<h2 id="seven-rows">Seven rows</h2>
<p><code>props &lt;slug&gt;</code> gains <code>[e]</code> -- on a draft, on a scheduled post and on a published one alike.</p>
<figure><img src="https://blogsh.app/posts/2026/what-a-post-is-not-what-it-says/01.png" width="720" height="330" alt="The properties screen: series, part, tags, type, and three flags with their current values" loading="lazy" decoding="async"><figcaption>The screen behind [e]. Two rows answer with the site&#39;s own default rather than yes or no -- that is the third state.</figcaption></figure>
<pre class="code-block"><code>Properties of part-three -- what the post is, not what it says:

  1) series          (none)
  2) part of series  (none)
  3) tags            spring
  4) type            text (from the content)
  5) unlisted        no
  6) lead image      (the site&#39;s own)
  7) chapter list    (the site&#39;s own)</code></pre>
<p>Two rows are pickers rather than prompts. The series row lists the series the site already has, each with the number of posts carrying it, and drafts count -- a series that so far exists on one unpublished post is offered too. It is a list because a series typed a second time is a second series; the last row is a field, for one the site does not have yet. <code>check</code> reports two names a few characters apart, but only after the parts are split between them. The type row lists the eight the engine ranks -- document, video, audio, image, chat, quote, link, text -- with the way back to letting the content decide above them, saying which type that is.</p>
<p>The tags row is a field, because a line of commas is how tags are written everywhere else here. Above it the screen prints up to fifteen of the site&#39;s most-used, and it reads the answer exactly as the front matter reads it: commas split, a leading hash off, brackets off, empties dropped. <code>[release, foto]</code> typed into that row is two tags, not two tags wearing punctuation.</p>
<h2 id="what-the-screen-is-for">What the screen is for</h2>
<p>The same post, the same change, two ways. The post has a link card at the top and one <code>small</code> span inside a paragraph. The change is its tags.</p>
<p><code>edit</code> stops before saving:</p>
<pre class="code-block"><code>Careful, saving this would lose: 1x small span.
Really continue? Type &quot;yes&quot;:</code></pre>
<p>Anything but that word cancels the save entirely. A <code>small</code> span is a formatting run markdown cannot write, so the round trip hands back the words without it -- and no block changes type on the way, which is why the guard counts spans as well as blocks.</p>
<p><code>[e]</code> changes the tags, leaves the post&#39;s <code>content</code> identical and asks nothing, because nothing is owed. The screen reads the file, replaces one key, writes the file back. Nothing is converted in between.</p>
<h2 id="three-states-not-two">Three states, not two</h2>
<p><code>unlisted</code> is a yes or a no. The other two are not.</p>
<p>A post that says nothing about a lead image is not a post saying no to one. It takes <code>layout.hero</code> from <code>site.yml</code>. A post that says nothing about a chapter list takes the engine&#39;s own rule: the list appears by itself from four headings up. So those rows read <code>yes</code>, <code>no</code>, or <code>(the site&#39;s own)</code>, and the third has to be reachable: choosing the row a third time lifts the key back out of the file rather than leaving a <code>false</code> behind.</p>
<h2 id="what-it-refuses">What it refuses</h2>
<p>A part number without a series. It says so instead of storing a field nothing reads. Clearing the series takes the part number with it, so a post rejoining a series later does not arrive with a position from a different one.</p>
<p>A number outside 1 to 9999. A <code>0</code> above all: a series has a first part and no zeroth one, and the build clamps a 0 to the front, so the screen would have printed &quot;part 0&quot; about a post every page of the site calls part 1. An answer it cannot use changes nothing and leaves the row showing what the post still carries.</p>
<p>The title, the date and the text. Those are the post rather than facts about it, and they remain the editor&#39;s business. The slug has its own key on the frame above, and on a published post so does the pin.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>A rebuild, once. Setting a series and choosing a type are two answers to one question, so the rebuild is offered when the screen is left, not after each row -- and a series or a tag is not one page, it is listings.</p>
<p>And it can refuse to save. The screen sits at a prompt for as long as somebody takes to answer, and the scheduled-publish cron runs every fifteen minutes. If the file changed underneath, the write is abandoned and says so, rather than putting a post the cron has just published back to a draft.</p>]]></description>
  <category>authoring</category><category>content</category>
</item>
<item>
  <title>The post that waited for a terminal</title>
  <link>https://blogsh.app/posts/2026/the-post-that-waited-for-a-terminal/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-post-that-waited-for-a-terminal/</guid>
  <pubDate>Sun, 06 Sep 2026 11:17:00 +0200</pubDate>
  <description><![CDATA[<p>The post is written on the tram: a title, four paragraphs, two photographs with their descriptions. The switch above Send says Draft, which is what it says unless it is moved. Send, and the answer card comes back with the preview address. Then it sits. Putting a draft out was a command, and a command wants a terminal, and the tram has none.</p>
<p>1.6 asked for that decision at the wrong end: publish at the moment of sending, before the blog has rendered anything, or see the blog&#39;s own page and then find a keyboard. 1.7 closes that, and then closes the road the answer takes back.</p>
<h2 id="one-file-down-the-same-connection">One file, down the same connection</h2>
<p>The answer card for a draft now carries a Publish button. Pressing it sends one file, called <code>publish.txt</code>, holding the slug and nothing else -- through the same share sheet, the same two shortcuts, the same SSH connection the post itself took.</p>
<figure><img src="https://blogsh.app/posts/2026/the-post-that-waited-for-a-terminal/01.png" width="430" height="930" alt="The writing page at /write/ on a phone, with the text, tags and the Send button" loading="lazy" decoding="async"><figcaption>The page a post is written on: the same page on any phone, and the only one the engine publishes as a file.</figcaption></figure>
<p>The receiver knows that shape: one file in the delivery, called that. It stores nothing. It reads the slug and runs <code>publish &lt;slug&gt; --yes --json</code>. The slug becomes an argument to a command, so it is checked as hard as a filename is -- lower-case letters, digits and dashes, no leading dash to be read as a flag, no newline in the middle to glue two lines into one word -- and anything else is refused here rather than explained by whatever it hits. A <code>publish.txt</code> arriving beside a markdown file is not a request; it is stored like any other file.</p>
<p><code>publish --yes --json</code> answers as one object -- <code>slug</code>, <code>path</code>, <code>state</code>, <code>url</code>, <code>deploy</code>, <code>warnings</code>, the shape <code>add --json</code> prints -- and leaves with zero whatever it says, because iOS Shortcuts throws away the output of a remote command that failed. Press the button twice and the second press is answered rather than obeyed: <code>already_published</code>, with the address.</p>
<h2 id="an-answer-the-page-goes-and-gets">An answer the page goes and gets</h2>
<p>The road back has one break in it that nothing on the server can mend. The reply travels as a URL, and a page kept on a phone&#39;s home screen runs with storage of its own: the URL opens in the browser, where the draft it is about does not exist. The draft stays on the home-screen copy, looking unsent. A phone that goes into a lift loses the same answer for a duller reason.</p>
<p>So the page stops waiting. Before it sends anything it picks a name for its answer -- sixteen hexadecimal characters out of the browser&#39;s random source, a fresh one for each send -- and writes it into the post as <code>receipt:</code>. The build then leaves a small file at <code>/write/r/&lt;name&gt;.json</code>. Here is one, whole:</p>
<pre class="code-block"><code class="language-json">{&quot;slug&quot;:&quot;on-the-tram&quot;,&quot;state&quot;:&quot;draft&quot;,&quot;title&quot;:&quot;On the tram&quot;,&quot;url&quot;:&quot;https://example.com/draft/41a0a9b77caef98a/on-the-tram/&quot;,&quot;warnings&quot;:[]}</code></pre>
<p>The page asks for that address every three seconds for five minutes, and says so if it never comes: a page that gave up in silence would be indistinguishable from a post that never arrived. Press Publish and it asks the same name again until the state says published.</p>
<p>The build writes it, which is what keeps it true: publishing the post rewrites the same file, the draft&#39;s preview address giving way to the public one; deleting the post stops it being generated and the sweep takes it away. An answer arriving is itself the proof that the build and the upload went through.</p>
<h2 id="what-is-in-it-and-what-is-not">What is in it, and what is not</h2>
<p>Five things: the slug, the state, the title, the address, and whatever the save had to complain about -- a picture whose size could not be read, a video that will make the reader wait. The phone is the one place with no terminal to read those in.</p>
<p>Nothing else, on purpose. Not the path the post has on the server. Not what the run said about the site rather than about the post -- a missing <code>base_url</code>, whatever the rebuild warned about -- because the file sits at a public address and its only protection is that its name is sixteen random characters. Anyone who has them can read it. The draft preview it names is the address the draft is readable at anyway.</p>
<h2 id="what-it-still-refuses-to-do">What it still refuses to do</h2>
<p>Anything about a post already on the blog. The page cannot open one, edit one, delete, rename, schedule, or take a published post back down. It writes one post, sends it, and offers to publish that one -- and only that one, because publishing takes the receipt this page minted. It is the 1.6 design with one step added, not a console growing on the site.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>Pressing Publish announces. It is the road <code>publish</code> takes at a desk -- the date settled, the post out on whatever networks the site has configured, the site rebuilt and deployed -- and there is no undo on the phone.</p>
<p>The five minutes run from the send, not from the reload: a page reopened an hour later does not resume asking. And on a site whose deploy is owed to the next scheduled run, the file on the server can be older than the page&#39;s patience. The page then says there is no answer yet, that the blog may still be building, and to go and look before sending again -- which is not the same sentence as &quot;it failed&quot;, and usually it was not.</p>
<h2 id="where-the-page-is">Where the page is</h2>
<p>It runs on this site, at <a href="/write/">/write/</a>. It is a demonstration, and it keeps nothing: what you type stays in your own browser, and sending needs a key that lives in a shortcut on the phone of whoever runs the blog. Open it, write in it, and nothing here moves.</p>]]></description>
  <category>authoring</category>
</item>
<item>
  <title>./blog.sh 1.7</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-7/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-7/</guid>
  <pubDate>Sat, 05 Sep 2026 23:23:33 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.7 is out. The page that writes a post on a phone now publishes it too, and it finds out what happened rather than being told: it mints a receipt before sending and asks the site for it every three seconds until the answer comes.</p>
<p>Beside it, the other half of the same idea — changing what a post is without opening what it says: its series, its tags, its type and three flags, from a screen. Then a link card written in the front matter, about fifty drawings a tag can wear, a video repacked on the way in so the reader does not wait for the whole file, and seventeen fixes.</p>
<p>#blogsh #ruby #staticsite</p>]]></description>
  <category>release</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 badge that says what a post is about</title>
  <link>https://blogsh.app/posts/2026/a-badge-that-says-what-a-post-is/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-badge-that-says-what-a-post-is/</guid>
  <pubDate>Sat, 05 Sep 2026 11:06:00 +0200</pubDate>
  <description><![CDATA[<p>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.</p>
<p>1.6 lets a tag carry an icon instead.</p>
<pre class="code-block"><code class="language-yaml">tag_icons:
  - tag: &quot;build&quot;
    icon_svg: &#39;&lt;svg viewBox=&quot;0 0 24 24&quot; …&gt;…&lt;/svg&gt;&#39;
  - tag: &quot;comments&quot;
    icon: &quot;chat&quot;</code></pre>
<h2 id="it-replaces-rather-than-adds">It replaces rather than adds</h2>
<p>On a post&#39;s date badge the tag&#39;s icon takes the place of the content-type icon; in the heading of <code>/tag/&lt;name&gt;/</code> it takes the place of the generic tag icon. That was the requester&#39;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.</p>
<h2 id="the-order-is-the-priority">The order is the priority</h2>
<p>Most posts carry more than one tag — 68% of them on the archive this was measured against — so <code>tag_icons</code> is a list, and the first entry a post matches is the one it wears. That rule was chosen because the tag a post was given first is usually an importer&#39;s rather than a subject: <code>twitter</code> alone opens 1,256 posts on that archive. The order in your config is your order, not the post&#39;s.</p>
<h2 id="eight-by-name-or-your-own">Eight by name, or your own</h2>
<p><code>icon</code> names one of the eight the engine ships — text, quote, chat, image, video, audio, link, document. <code>icon_svg</code> is yours: drawn on the same 24-unit grid, stroked in <code>currentColor</code> so it follows the light and dark themes, with scripts, styles and event handlers stripped before it reaches a page. <code>doctor</code> checks every entry: whether the name is one the engine has, whether a hand-written SVG contains an <code>&lt;svg&gt;</code> at all, and whether it is drawn to the same scale as the rest. An unknown name used to draw an empty space and say nothing.</p>
<h2 id="on-this-site-since-today">On this site, since today</h2>
<p>The seven tags in this site&#39;s menu have icons now — a bulb for Why, a pen for Writing, a box for Archive, a spanner for Engine, the engine&#39;s own chat icon for Comments, an eye for Looks, a package for Releases. Look at the badge on the left of this post, then at the badges on the front page, and you will see the rule at work: a post tagged <code>appearance, content</code> wears the eye, because <code>appearance</code> is named and <code>content</code> is not.</p>
<p>The whole thing was asked for by one person, shaped by his three points, and shipped with the request still open so he could say where it was wrong. He did not have to.</p>]]></description>
  <category>appearance</category><category>content</category>
</item>
<item>
  <title>Where a reader carries a post</title>
  <link>https://blogsh.app/posts/2026/where-a-reader-carries-a-post/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/where-a-reader-carries-a-post/</guid>
  <pubDate>Fri, 04 Sep 2026 16:52:00 +0200</pubDate>
  <description><![CDATA[<p>A post gets read, and then it goes somewhere: into a toot, a message, an email to one person. Until now the reader did that by copying the address out of the bar. 1.6 puts a row of controls under the post for it — off unless <code>share:</code> names what you want, drawn in the order you name it.</p>
<pre class="code-block"><code class="language-yaml">share:
  - mastodon
  - bluesky
  - email
  - copy
  - system</code></pre>
<h2 id="what-is-prefilled-and-what-is-not">What is prefilled, and what is not</h2>
<p>The post&#39;s name and its address, where the target takes both. Facebook and LinkedIn take the address alone and read the name off the page. The reader writes the part that is theirs. <code>bluesky</code>, <code>email</code>, <code>facebook</code>, <code>linkedin</code>, <code>threads</code> and <code>x</code> are plain links, one address each, no script behind them.</p>
<h2 id="three-that-are-not-links">Three that are not links</h2>
<p>The fediverse has as many addresses as it has instances, and a page cannot know which one is the reader&#39;s. So <code>mastodon</code> is a button that asks — in a row that opens under the controls, in the site&#39;s own type — and remembers the answer in that browser afterwards. Nothing about the reader leaves the page; the instance name is kept where they typed it.</p>
<p><code>copy</code> puts the address on the clipboard. <code>system</code> hands the post to the operating system&#39;s own share sheet, which on a phone is Signal and WhatsApp and Telegram at once, and on a desk is whatever the system offers.</p>
<p>Those three appear only where they can work. A browser without a clipboard API gets no copy button, a desk without a share sheet gets no system button, and a block left with nothing to draw hides itself rather than standing a heading over an empty row.</p>
<h2 id="why-there-is-no-pixelfed">Why there is no Pixelfed</h2>
<p>Pixelfed has no address a page can hand a post to. A button that opened nothing would be worse than none, so there is none, and the changelog says so under &quot;not fixed, on purpose&quot; rather than leaving you to wonder.</p>
<h2 id="one-new-file">One new file</h2>
<p><code>assets/js/share.js</code> is a file every page now loads. On a site that never sets <code>share:</code> it does nothing at all; it is still a file, and a deploy that misses it leaves a 404 in the console. <code>git pull</code>, rebuild, deploy — and it is there.</p>
<p>The controls make no request to anyone until the reader presses one. That is the rule for everything the engine puts on a page, and this row is no exception: the share buttons of the web are usually the one thing on a page that phones home before you touch them, and these do not.</p>]]></description>
  <category>appearance</category><category>comments</category>
</item>
<item>
  <title>Written on a train</title>
  <link>https://blogsh.app/posts/2026/written-on-a-train/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/written-on-a-train/</guid>
  <pubDate>Fri, 04 Sep 2026 10:51:00 +0200</pubDate>
  <description><![CDATA[<p>A reader who was thinking about moving to this engine wrote to say he would stay where he was. He manages his blog from a phone, on the road, and a blog that can only be worked from a terminal was no use to him however fast it built. He was right.</p>
<p>1.6 answers him with two things, and with one thing it refuses to do.</p>
<h2 id="a-page-on-the-blog-itself">A page on the blog itself</h2>
<p>Set <code>write: true</code> and the build publishes a small editor at <code>/write/</code>: a title, the text, tags, photographs and video, each picture with its description. It wears the blog it writes to — its name, its palette, its language — and it offers the blog&#39;s own tags as you type, the ones used in the last twelve months first, so a tag is tapped rather than spelt a second way. A preview shows the post in the blog&#39;s own stylesheets. A row of marks above the text wraps what is selected and comes off again on a second tap. Above the send button, a Draft | Publish switch.</p>
<p>Everything is kept in the browser between visits — the text in local storage, the pictures&#39; bytes in IndexedDB, because five megabytes is all local storage gets and one phone video is more than that. When somebody interrupts you on the platform, nothing is lost.</p>
<p>The page is off by default, marked <code>noindex</code>, and holds no secret. It is served from the blog itself, so the same <code>git pull</code> moves both ends of the protocol and they cannot drift apart.</p>
<h2 id="a-key-and-nothing-new-on-the-network">A key, and nothing new on the network</h2>
<p>Sending is two iOS shortcuts. One takes the files from the share sheet, the other opens the connection, because the shortcut that receives files may not open one. The connection is the SSH the server already has, and the key lives in the shortcut, on the phone. On the server, that key runs one forced command: <code>scripts/receive.sh</code>, which takes the whole post over one connection — pictures first, the markdown last — checks every name and decodes every body before it writes anything, and hands the markdown to <code>add</code>.</p>
<p>Nothing new listens. There is no login, no session, no token in a form. A delivery over the ceiling hears <code>too_large</code> instead of a closed channel; one that goes quiet is dropped. The server&#39;s answer comes back to the page in the address bar, in the reader&#39;s language, and the draft is cleared from the phone once the server has the post.</p>
<h2 id="what-it-refuses-to-do">What it refuses to do</h2>
<p>It does not edit old posts. It does not delete, rename, schedule, back up or show statistics. It is one page on which a post can be written and sent, and nothing more.</p>
<p>That is the design, not the shortfall. Editing old posts from a phone would need what this engine has never had — accounts, sessions, a login — and the moment those exist, so does everything that goes wrong with them. A page that can only add, over a key that can only run one command, has nothing to defend.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>The first time is not two minutes: a key on the server, two shortcuts on the phone, one walk through the whole thing. Half an hour when nothing snags. The second time is opening a page and writing.</p>
<p>The page shipped in 1.6, two days of work after the message that asked for it. He was right, and the right answer to being told so is two days of work rather than a paragraph about why it has to be this way.</p>]]></description>
  <category>authoring</category><category>philosophy</category>
</item>

  </channel>
</rss>
