<?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/cs/</link>
    <atom:link href="https://blogsh.app/cs/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>cs</language>
    <lastBuildDate>Sat, 19 Sep 2026 17:32:40 +0200</lastBuildDate>
    <item>
  <title>Jak jsem si na sdíleném hostingu nastavil blog.sh</title>
  <link>https://blogsh.app/cs/posts/2026/jak-jsem-si-na-sdilenem-hostingu-nastavil-blog-sh/</link>
  <guid isPermaLink="true">https://blogsh.app/cs/posts/2026/jak-jsem-si-na-sdilenem-hostingu-nastavil-blog-sh/</guid>
  <pubDate>Sat, 19 Sep 2026 17:32:40 +0200</pubDate>
  <description><![CDATA[<p class="link-block"><a href="https://pavelchcepsat.cz/posts/2026/how-did-i-set-up-blog-sh-on-my/"><strong>How did I set up blog.sh on my shared hosting</strong></a><br>pavelchcepsat.cz</p>
<p>Pavel přestěhoval WordPressový blog ze sdíleného hostingu na blog.sh, a to přes obyčejné FTP. Postup popisuje krok za krokem: export WXR, import, rclone, zkušební běh, nasazení.</p>
<p>Stojí za přečtení, pokud tvůj web běží na hostingu, který nabízí FTP a nic víc.</p>
<p>#blogsh #wordpress #staticsite</p>]]></description>
  <category>migration</category><category>deploy</category>
</item>
<item>
  <title>Co se načte poslední, vyhrává</title>
  <link>https://blogsh.app/cs/posts/2026/co-se-nacte-posledni-vyhrava/</link>
  <guid isPermaLink="true">https://blogsh.app/cs/posts/2026/co-se-nacte-posledni-vyhrava/</guid>
  <pubDate>Sat, 19 Sep 2026 09:49:45 +0200</pubDate>
  <description><![CDATA[<p>Dva skutečné weby nosí vzhled, který tento engine nikdy nedodával. sh.cynicky.blog je oblečený jako Ghost — úvodní karta přes celou šířku, tečkovaný rámeček, dlaždice místo prostého seznamu. arch-linux.cz je oblečený jako téma pro Hugo, konkrétně Blowfish — kompaktní lišta, blok s profilem, karty rozložené jako úplně jiná domovská stránka. Ani jeden nevytvořil fork enginu. Ani jeden neupravil šablonu. Oba dál přijímají obyčejný <code>git pull</code>.</p>
<h2 id="jeden-stylesheet-nacteny-jako-posledni">Jeden stylesheet, načtený jako poslední</h2>
<p><code>site.extra_css</code> v <code>config/site.yml</code> udává cestu nebo seznam cest, které se načtou po vlastních <code>colors.css</code> a <code>site.css</code> enginu. To je celý mechanismus — skin je jeden tvůj soubor, který překresluje, co už na stránce je, a engine do něj nikdy nezapisuje, takže s ním aktualizace nikdy nemůže být v konfliktu.</p>
<p>Jen místní cesty: každá stránka nese <code>style-src &#39;self&#39;</code> a stylesheet z jiného hostitele by prohlížeč prostě zahodil bez jediné chyby, kterou by kdokoli uviděl — sestavení proto vzdálenou cestu odmítne nahlas, dřív než to ticho nastane.</p>
<p>Jestli ten soubor patří do gitu, je samostatné rozhodnutí — commitni ho a druhý počítač už má tvůj vzhled, vynech ho a veřejný repozitář nikdy nebude nést design, který veřejný být neměl. Ani vlastní <code>.gitignore</code> enginu za tebe nerozhoduje ani jedním směrem.</p>
<h2 id="pravidlo-ze-ktereho-plyne-vsechno-ostatni">Pravidlo, ze kterého plyne všechno ostatní</h2>
<p>Načtení jako poslední znamená, že tvoje pravidla vyhrávají při stejné specifičnosti — včetně pravidel, která engine napsal pro úzké obrazovky, a to není ta část, u které by někdo čekal, že proti ní bude vyhrávat.</p>
<p>Skin sh.cynicky.blog nechává obsah plout vedle článku a dal mu šířku v procentech, aby odpovídala jeho vlastní šířce sazby. Na desktopu to vypadalo dobře. Pod 700 px má engine vlastní přeskládání: obsah se přesune nad text a pravidlo šířky se změní na <code>auto</code>, protože při takové velikosti už vedle není místo. Procenta ze skinu o tom nevěděla a platila dál — načtení jako poslední znamená vyhrát vždy, ne jen když se to hodí. Článek vyšel 155 pixelů široký na 390pixelové obrazovce telefonu.</p>
<p>Oprava obalí přepis do rozsahu, kde má skutečně platit:</p>
<pre class="code-block"><code class="language-css">@media (min-width: 861px) {
  .toc { width: 350px; }
}</code></pre>
<p>Pod touto šířkou skin mlčí a přeskládání enginu může dělat tu jedinou práci, kterou už dělalo správně.</p>
<h2 id="na-cem-engine-spoleha-aniz-to-rika">Na čem engine spoléhá, aniž to říká</h2>
<p>Dvě menší pasti vzešly z druhého skinu, z arch-linux.cz, a obě mají stejný tvar: pravidlo, které vypadá čistě dekorativně, se ukáže být někde jinde nosné.</p>
<p>Zrušit navigační liště enginu přilepení je rozumné přání skinu. Na arch-linux.cz kvůli tomu ale přestalo reagovat na kliknutí i tlačítko pro přepínání motivu — ne proto, že by se tlačítko rozbilo, ale proto, že lišta seděla v CSS gridu, kde <code>z-index</code> funguje i bez <code>position</code>, a odlepená lišta si svou vyšší vrstvu ponechala a neviditelně ležela přes tlačítko. Přišel na to admin toho webu tím, že na přepínač skutečně klikl; na screenshotu by to nebylo vidět nikdy.</p>
<p>Druhá, menší: <code>max-width</code> na flex položce s <code>flex-basis: 100%</code> zmenší hypotetickou šířku položky, místo aby omezila její obsah — a tak jednou tělo článku skončilo vedle vlastního řádku s metadaty místo pod ním. Omezení patří na potomky, ne na box kolem nich.</p>
<h2 id="jedna-stranka-vypisu-ne-cely-feed">Jedna stránka výpisu, ne celý feed</h2>
<p>Stylesheet neumí číst adresu, takže úvodní stránka a <code>/page/2/</code> byly pro CSS dřív k nerozeznání — což začalo vadit ve chvíli, kdy kterýkoli skin chtěl úvodní kartu jen na první stránce. Od 1.3.2 říká <code>&lt;body&gt;</code> každého výpisu, o jaký druh jde:</p>
<pre class="code-block"><code class="language-css">.page-first .post-list-item:first-child { /* úvodní karta */ }
.page-cont  .archive-note { /* jen na pokračováních */ }</code></pre>
<p>Dvě třídy místo jedné, záměrně — jediná značka na pokračováních by znamenala napsat vzhled první stránky bezpodmínečně a pak ho vlastnost po vlastnosti rušit, a pravidlo, které ruší jiné pravidlo, je přesně to, které pozdější úprava tiše přestane rušit.</p>
<figure><img src="https://blogsh.app/cs/posts/2026/co-se-nacte-posledni-vyhrava/01.png" width="1280" height="1100" alt="sh.cynicky.blog" loading="lazy" decoding="async"></figure>
<p>Nahoře je sh.cynicky.blog — úvodní karta přes celou šířku, tečkovaný rámeček, dlaždice místo prostého seznamu a za tím ani jeden upravený soubor enginu. Vyhledávání zůstává polem v liště enginu, ne modálním oknem, které by mu dal sám Ghost, protože přestavět ho na modál by znamenalo vlastnit chování, které engine nikdy neslíbil. Žádný ze skinů, ani tenhle, ani ten z arch-linux.cz, se nikdy nesnažil o kopii k nerozeznání do posledního pixelu. Věrnost nebyla cílem. Cílem bylo patřit do rodiny.</p>
<p>Skin přestane být levným nástrojem ve chvíli, kdy potřebuje značky, které engine nestaví — v tu chvíli je poctivou volbou upravit šablonu, ne stylesheet, který se snaží něco předstírat. Než se k té hranici dojde, žádný systém témat není potřeba: žádné háčky, žádný registr, žádná hierarchie přepsání, kterou by bylo třeba držet v hlavě. Celou funkci nese jediný fakt. Tvůj soubor se načítá poslední.</p>
<p>—</p>
<p>P.S. — sh.cynicky.blog není tam, kde žije samotný cynicky.blog. Skutečný web stále běží na platformě Ghost a je aktualizován téměř každý den; sh.cynicky.blog má stejný vzhled a je udržován jako samostatná funkční instalace. Jeho autor se zabýval skutečnou migrací a rozhodl se zůstat tam, kde už byl – stojí za to to říci na rovinu, místo aby doménové jméno naznačovalo něco jiného.</p>]]></description>
  <category>appearance</category><category>philosophy</category>
</item>
<item>
  <title>./blog.sh 1.8</title>
  <link>https://blogsh.app/cs/posts/2026/blog-sh-1-8/</link>
  <guid isPermaLink="true">https://blogsh.app/cs/posts/2026/blog-sh-1-8/</guid>
  <pubDate>Mon, 14 Sep 2026 10:10:04 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.8 je venku — <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.8">vydání na GitHubu</a>.</p>
<p>Vydání o tom, co se stane, když se něco pokazí. Každá stránka, feed a index se teď zapisuje do dočasného souboru a přejmenuje na místo, takže uložení přerušené plným diskem nebo sestavení zastavené uprostřed zápisu už nezanechá stránku v poloviční délce ani příspěvek o 0 bajtech, u kterého je starý text pryč. Studené sestavení tím zdraží zhruba o pětinu; sestavení z cache ne, takže běžný den publikování stojí přesně tolik co dřív.</p>
<p>Dvojí doručení už neznamená, že se něco stane dvakrát: příspěvek odeslaný z telefonu dvakrát nebo jedna potvrzenka doručená ve dvou kopiích najednou je teď jeden příspěvek a opětovný import exportu vlastního enginu už z 1200 příspěvků nedělá 2400. A adrese se už nevěří jen proto, že jako adresa vypadá — <code>../</code> ve slugu, v názvu mediálního souboru nebo v cíli přesměrování zůstane uvnitř webu, lokální nasazení zůstane uvnitř svého adresáře a přesměrování z feedu nemůže poslat stahování na <code>localhost</code>.</p>
<p>Před tagem prošlo pět úzce zaměřených revizí zápisové cesty, výstup sestavení, import a export, <code>check</code> a série; všechno, co našly, je opravené a přišpendlené testem, který na starém kódu padá. Většinu z toho nikdo nikdy nepotká. Co potkat můžeš: <code>check</code> se ptá na totéž co sestavení, takže stránka se slugem <code>index.html</code> už nezastaví každé sestavení, zatímco <code>check</code> prohlašuje archiv za v pořádku; export se už nezastaví na prvním rozbitém příspěvku a vrátí se domů i s prázdnými odstavci a vloženými prvky beze změny; série o dvaceti a více dílech se otevře od prvního dílu; a díl s číslem <code>08</code> už o své číslo nepřijde kvůli osmičkové soustavě.</p>
<p>Není co migrovat — <code>git pull</code>, sestavit, nasadit. První sestavení vykreslí každou stránku jednou; na mém vlastním archivu o 6 600 příspěvcích nahrálo následné nasazení jediný soubor, tahák k Markdownu. <a href="https://github.com/DanielSnor/blog.sh/blob/main/CHANGELOG.md">Celé poznámky →</a></p>]]></description>
  <category>release</category>
</item>
<item>
  <title>Sestavení neví, kam míří</title>
  <link>https://blogsh.app/cs/posts/2026/sestaveni-nevi-kam-miri/</link>
  <guid isPermaLink="true">https://blogsh.app/cs/posts/2026/sestaveni-nevi-kam-miri/</guid>
  <pubDate>Sat, 12 Sep 2026 19:26:30 +0200</pubDate>
  <description><![CDATA[<p>Někdo postaví celý web na notebooku, naimportuje do něj desetiletí starých příspěvků, prohlédne si každou stránku — a nic z toho ještě nikam nedorazilo. V <code>env.sh</code> není řečeno kam a nic dalšího na to nečeká: sestavení proběhne, archiv projde kontrolou, každá stránka se vykreslí na <code>localhost</code>. Sestavení neví, kde má web nakonec skončit, a vědět to nepotřebuje.</p>
<p>Totéž sestavení může začít na notebooku, o rok později se přestěhovat na pronajatý VPS a potom skončit na instanci Cloudronu. Mezi těmito přesuny se v obsahové pipeline nemění nic. Mění se odpověď na jedinou otázku — kam mají soubory jít — a odpovědět na ni může šest věcí.</p>
<h2 id="co-o-tom-rozhoduje">Co o tom rozhoduje</h2>
<p><code>DEPLOY_BACKEND</code> v <code>env.sh</code> určuje cíl; hodnoty pod ním závisejí na tom, který to je. Všechno za tímto řádkem je u všech šesti stejné. Každý backend si vede vlastní manifest — SHA-256, velikost a čas změny každého souboru, který už nahrál — takže nasazení posílá vždy jen to, co se od minulého změnilo. <code>deploy-web.sh --dry-run</code> tento manifest přečte, aniž by se čehokoli dotkl.</p>
<p>Takhle vypadá výstup, spuštěný právě teď proti vlastnímu backendu tohoto webu:</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>Od posledního skutečného nasazení se nic nezměnilo, takže to manifest oznámí a tím to končí — stejnou větu by vypsal cíl <code>git</code> nebo <code>rclone</code>, jen s jiným řádkem nad ní, který říká, kdo odpověděl.</p>
<p>Manifest je ale pro každý backend zvlášť, takže když stejné sestavení poprvé namíříš na jiný cíl, první nasazení nahraje všechno — přesun je jedno úplné nahrání, ne opakování čehokoli, co sestavení udělalo.</p>
<h2 id="pravidlo-urcuje-ten-nejprisnejsi">Pravidlo určuje ten nejpřísnější</h2>
<p>Jediný soubor nad 100 MB se odmítne při uložení i znovu při nasazení a limit je na všech šesti backendech stejné číslo — ne proto, že by všechny cíle sdílely tentýž strop, ale proto, že engine vybere ten nejpřísnější a všechny se ho drží. Web určený pro <code>rsync</code> tak zůstane nasaditelný i přes <code>sftp</code> a nikdo nemusí zjišťovat na vlastní kůži, který backend byl shovívavý.</p>
<p>To prozrazuje, že tohle není šest nahrávacích nástrojů vedle sebe: je to jeden model nasazení — jeden tvar manifestu, jedna bezpečnostní pojistka, jeden limit velikosti souboru — se šesti způsoby, jak se dostat na druhý konec. Backendy se liší tím, jak bajty putují; co smí putovat, není jejich věc.</p>
<h2 id="tech-sest">Těch šest</h2>
<p><b>surfer</b> je výchozí — nastav <code>SURFER_URL</code> a <code>SURFER_TOKEN</code> a nic dalšího říkat nemusíš. Je to Cloudron Surfer, hosting, na kterém běží i tento projekt.</p>
<p><b>local</b> zapisuje přímo do adresáře, a to pro kořenový adresář dokumentů za vaším vlastním serverem nginx nebo Caddy. O HTTPS se v každém případě stará webový server; CSP tohoto modulu je dodáván jako meta tag, takže v tomto ohledu není třeba nic konfigurovat, ani kdyby tento tag chyběl.</p>
<p><b>rsync</b> míří na libovolný server dostupný přes SSH, na kterém je nainstalovaný rsync — nejuniverzálnější z šesti, protože to popisuje většinu VPS i dobrou část sdíleného hostingu.</p>
<p><b>git</b> pushuje na GitHub, GitLab nebo Codeberg Pages, při každém nasazení force-pushem celé sestavení jako jediný commit. Vlastní doména vyžaduje nastavené <code>GIT_PAGES_CNAME</code>, protože hostitel drží tu adresu v souboru, který leží přímo ve větvi, již snapshot přepisuje.</p>
<p><b>rclone</b> dosáhne na S3, R2, B2, WebDAV a cokoli dalšího, s čím umí <code>rclone config</code> komunikovat; přihlašovací údaje k bucketu zůstávají v konfiguraci rcloneu, ne v <code>env.sh</code>.</p>
<p><b>sftp</b> je pro hostitele, kteří nemají rsync ani git: obyčejné SSH, dávkový <code>sftp</code>, jedno spojení na nasazení. Ověřování klíčem je potřeba nastavit předem — dávková úloha nemá terminál, ve kterém by odpověděla na dotaz na heslo, a spojení selže, místo aby čekalo na odpověď, která nepřijde.</p>
<p>Na kterém z těch šesti web běží, je jediný řádek v <code>env.sh</code>, zvolený jednou a jen zřídka přehodnocovaný — protože těžší polovina problému nikdy nebyl backend. Bylo to zajistit, aby nezáleželo na tom, který se vybere.</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><category>release</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><category>release</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><category>release</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><category>release</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><category>release</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>
<item>
  <title>A post handed over as a file</title>
  <link>https://blogsh.app/posts/2026/a-post-handed-over-as-a-file/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-post-handed-over-as-a-file/</guid>
  <pubDate>Thu, 03 Sep 2026 16:42:00 +0200</pubDate>
  <description><![CDATA[<p>Until 1.6 a post came from a person at a terminal. The wizard asked its questions, opened an editor, asked what to do at the end. Nothing else could write one: not a script, not a cron job, not a phone.</p>
<pre class="code-block"><code class="language-bash">./blog.sh add post.md</code></pre>
<p>does the same work with the markdown handed over instead of typed. No editor, no questions, no dialog at the end. A bare filename is looked for in <code>incoming/</code>, so the file can arrive by the same upload as the photographs, and it is deleted once the post is written.</p>
<h2 id="where-the-wizard-would-ask-this-refuses">Where the wizard would ask, this refuses</h2>
<p>A person can be asked. A script cannot, so every question becomes a refusal that writes nothing: a photo that has not finished uploading, an empty body, a file that is not text, a second filename where one was expected. The alternative — guessing — was measured on the old code, where a byte-order mark in front of the header was taken for body text: three invisible bytes from a Windows editor and the post arrived with no title and no tags, named after the words &quot;title:&quot; and &quot;tags:&quot;, exit 0. 1.6 reads the header through them.</p>
<h2 id="json-one-object-nothing-else"><code>--json</code>: one object, nothing else</h2>
<p>With <code>--json</code> the answer is a single object on standard output. A post that was written carries <code>slug</code>, <code>path</code>, <code>state</code>, <code>url</code>, <code>deploy</code> and <code>warnings</code>, every key always present. A refusal carries <code>ok: false</code>, the reason as a code and one sentence.</p>
<p>And a refusal leaves with <b>zero</b>. That is deliberate, and it took a phone to learn it: iOS Shortcuts throws away the output of a remote command that failed, so every refusal a phone could meet came back as a bare status and nothing else — exactly when the reason was the whole point. The status answers the one question the object cannot: whether an answer arrived at all. A non-zero status means the engine is missing or the machine is not set up. Without <code>--json</code> nothing changes: prose on stderr and a non-zero status, as always.</p>
<h2 id="draft-unless-the-file-says-otherwise">Draft, unless the file says otherwise</h2>
<p>The file stops at the draft. <code>publish: yes</code> in its front matter is the one thing a file may ask for that the wizard never could: to go straight out, the date settled, the announcement sent, the site rebuilt, the answer carrying the public address. A post sent from a train has no desk to come back to, so the choice is made when it is sent. Absent, or anything but yes/true/1, is a draft.</p>
<p>At a desk the same two decisions are flags: <code>publish &lt;slug&gt; --yes</code> answers the dialog in advance, and <code>--no-announce</code> puts the page up and sends nothing to Mastodon or Bluesky — <code>toot</code> can still send it by hand afterwards. One thing <code>--yes</code> will not decide for you: a post dated outside the recent window is published but not announced, and the run says so. Announcing is the one step that cannot be taken back.</p>
<h2 id="untrusted"><code>--untrusted</code></h2>
<p>Markdown that arrived over a wire gets one more rule: a picture reference may name only a bare filename. Without it, <code>![](/etc/passwd)</code> reads the file into the post&#39;s media — right at a desk, where whoever typed it has the file anyway, and a door the moment the markdown comes from somewhere else.</p>
<p>None of this adds an API, a token or a listening port. A file is written somewhere the engine can see it, and a command runs. That is the whole integration surface, and it is the same one a person has.</p>]]></description>
  <category>authoring</category><category>build</category>
</item>
<item>
  <title>The time between deciding and the site saying so</title>
  <link>https://blogsh.app/posts/2026/the-time-between-deciding-and-the-site-saying/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-time-between-deciding-and-the-site-saying/</guid>
  <pubDate>Thu, 03 Sep 2026 10:41:00 +0200</pubDate>
  <description><![CDATA[<p>Publishing a post used to cost the size of the archive, not the size of the change. A post dated today alters a dozen files. The build rendered every page in the archive and read every one back off disk to find that out.</p>
<p>On the archive this engine was built around — 6,639 posts, on the server it lives on — that was 47 seconds for a rebuild that changed nothing, and 48 from pressing publish to the post being out. Fine for one post in the morning. Less fine for fixing a typo in ten of them.</p>
<h2 id="what-the-build-remembers-now">What the build remembers now</h2>
<p>1.6 keeps a record of what went into each page: the post, the template, the locale, the configuration, the engine itself. On the next build it renders only the pages whose record no longer matches, and the rest cost one <code>stat</code> each instead of a full render and a read-back.</p>
<p>The same archive, measured the same way: a rebuild that changes nothing takes a seventh of what it did. Publishing a post dated today takes about two fifths — 16 seconds of build and 2 of upload, where it was 48 — and the build says so itself: <i>Pages already up to date, not rebuilt: 10,160</i>. The ratio is what travels; the seconds are one machine&#39;s.</p>
<h2 id="what-it-still-cannot-skip">What it still cannot skip</h2>
<p>A post dated into the early 2000s costs four fifths of a full build, and there is no way around that. A post landing there moves every listing page between the front page and where it lands, and those pages genuinely changed. The cache skips what did not change; it cannot skip what did. Importing an archive is backdating several thousand times over, so an import is a full build, as it always was — which is also why an import should land in one go rather than post by post.</p>
<p>Editing a template, a locale or <code>site.yml</code> throws the whole record away, because any of those can change every page. So does a change of timezone, including the one a system update makes without you touching anything. A stylesheet is the exception worth knowing: pages link it, they do not embed it, so editing one changes the stylesheet and nothing else — the build after it renders the pages once more and finds nothing new to upload but the one file.</p>
<h2 id="a-note-not-an-authority">A note, not an authority</h2>
<p>The record is an optimisation and never the truth about the site. Anything it cannot vouch for is built the old way: a record that is missing, truncated, in a different format, or left by a build that died halfway. A page somebody deleted or damaged by hand in <code>public.nosync/</code> comes back on the next build, because the record is checked against what is actually on disk. And <code>public.nosync/</code> is still swept of anything the build does not produce, cache or no cache.</p>
<p>It lives in <code>.build_cache.json</code> in the installation directory — gitignored, one per machine, always safe to delete. Deleting it costs one slow build and nothing else, which is the whole point of keeping the archive as files: the note about the files is just another file.</p>
<pre class="code-block"><code class="language-bash">./blog.sh rebuild --full        # build every page again, then deploy</code></pre>
<p>Nothing to migrate. The first build after <code>git pull</code> is a full one and writes the record; every build after it reads it.</p>]]></description>
  <category>build</category><category>content</category>
</item>
<item>
  <title>./blog.sh 1.6</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-6/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-6/</guid>
  <pubDate>Thu, 03 Sep 2026 08:25:43 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.6 is out.</p>
<p>The build stopped rebuilding what nobody changed: a rebuild that changes nothing costs a seventh of what it did, an ordinary publish about two fifths. A post can be handed over as a file — by a script, a cron job, or the new page at /write/ on the blog itself, which sends from a phone over the SSH the server already has. Nothing new listens on the network.</p>
<p>Around those: a row of share controls under a post, an icon a tag can carry, a way out of the trash and the versions, and a photograph stored once instead of twice.</p>
<p>#blogsh #ruby #staticsite</p>]]></description>
  <category>release</category>
</item>
<item>
  <title>Everything else in 1.5</title>
  <link>https://blogsh.app/posts/2026/everything-else-in-1-5/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/everything-else-in-1-5/</guid>
  <pubDate>Wed, 02 Sep 2026 10:31:00 +0200</pubDate>
  <description><![CDATA[<p>Five posts covered the shape of this release. Here is the rest of it, a paragraph each.</p>
<h2 id="a-copy-button-on-code-blocks">A copy button on code blocks</h2>
<p>Issue #44, from somebody running a blog full of terminal how-tos. It is always there rather than on hover: a phone has no hover, and a button only a mouse can find is one half the readers never get. Only on code blocks — a chat is a <code>&lt;dl&gt;</code>, inline code is a bare <code>&lt;code&gt;</code> — so it hangs on the block <i>type</i>, not on the tag. On an <code>http://</code> install there is no button rather than a dead one, and it copies what the block stores, not what wrapped on screen.</p>
<h2 id="two-things-you-will-see">Two things you will see</h2>
<p>Every listing by content type has an icon now. The other four kinds of listing got theirs in earlier releases, which left these seven looking unfinished; the quotation mark is filled rather than stroked, because outlined at twenty pixels the pair closed up into something that read as the digits 99. And the page is framed on all four sides: a heavy rule has closed it off top and bottom since 1.3, but the sides stayed open, so on a wide screen the text had a beginning and an end and no shape. Off below 700 pixels, where the column already reaches both edges.</p>
<h2 id="where-one-paragraph-ended-and-the-next-began">Where one paragraph ended and the next began</h2>
<p>A search snippet is a post&#39;s text with the paragraphs taken out, so the last words of one and the first of the next meet as one broken sentence. A middle dot stands between them now — not an ellipsis, which already means &quot;cut here&quot; at the end of every snippet, and one mark with two meanings is how a reader learns to trust neither. On the reference archive, 593 of the 1,636 titled posts run past their first paragraph.</p>
<h2 id="the-appearance-button-in-a-window-that-refuses-to-remember">The appearance button, in a window that refuses to remember</h2>
<p>Safari&#39;s private windows — and any profile with site data blocked — throw when <code>localStorage</code> is merely <i>read</i>, not only when written. That threw out of the last line of <code>theme-toggle.js</code>, so the code wiring the button up never ran: no cycling, no symbol, nothing. Both sides are guarded now, and the cycle asks the page which theme it is showing rather than the storage it may not have.</p>
<h2 id="thirty-four-lines-of-your-own-configuration">Thirty-four lines of your own configuration</h2>
<p>Choosing a palette in <code>./style.sh</code> deleted them. The last colour key is the last active key in its section, and the commented-out <code>fonts:</code> block underneath holds a line that uncomments to the same indentation as a colour — so the writer claimed it, and everything below it, as part of what it was rewriting. The file came back without its documented block, and the wizard reported success.</p>
<h2 id="the-other-door-to-a-photograph">The other door to a photograph</h2>
<p><code>./blog.sh add</code> strips the coordinates out of a photograph, exactly as the documentation promises. <code>./blog.sh edit</code> — the path people use far more — had grown a bare copy of the same work, and did not. Two implementations of one promise is one too many.</p>
<h2 id="01-jpg-and-01-jpg">01.JPG and 01.jpg</h2>
<p>An importer keeps the source URL&#39;s extension exactly as it was, case and all, and <code>01.JPG</code> is an ordinary name in a real archive: Posterous served <code>IMG_2669.JPG</code>, and a decade of cameras wrote nothing else. The allocator compared bytes and handed a new picture <code>01.jpg</code> believing it free; the copy asks the <i>volume</i>, and on macOS, or any Windows share, <code>01.JPG</code> answers for <code>01.jpg</code>. So the copy was skipped, the arrival&#39;s bytes were never written anywhere, the post showed the old photograph under both names — and <code>check</code> reported a reassuring &quot;misnamed&quot;. Names are folded on every volume now: one that is free on Linux and taken on macOS is a picture that vanishes when somebody moves their site.</p>
<h2 id="somebody-else-s-stylesheet-inside-your-post">Somebody else&#39;s stylesheet, inside your post</h2>
<p>An imported embed&#39;s HTML was rendered with its scripts, style blocks and stylesheet links intact. The site&#39;s policy already stopped the scripts — but the feed carries the same HTML and has no policy at all, and the style block was never inert anyway: <code>style-src</code> has to carry <code>unsafe-inline</code> for a post&#39;s own colour formatting. Both Instagram embeds in this house&#39;s archive carry <code>body &gt; iframe { min-width: auto !important }</code>, reaching out of the embed to every iframe on the page. Nothing hostile; simply not this site&#39;s to decide.</p>]]></description>
  <category>content</category><category>build</category>
</item>
<item>
  <title>An archive you cannot see is not empty</title>
  <link>https://blogsh.app/posts/2026/an-archive-you-cannot-see-is-not-empty/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/an-archive-you-cannot-see-is-not-empty/</guid>
  <pubDate>Tue, 01 Sep 2026 16:22:00 +0200</pubDate>
  <description><![CDATA[<p>This site&#39;s approved comments and its post counters are two small JSON files, refreshed by a cron job every half hour and uploaded with the rest of the site.</p>
<p>Through 1.4, a tick that ran while the archive was not there would narrow both files to nothing and upload them. An absent or empty content directory answers with an empty list and no error, and an empty list reads exactly like &quot;every announced post was deleted&quot;. The live site lost every approved comment and every counter at once. Exit 0, nothing said, nothing mailed.</p>
<p>The archive lives on a synced volume. A volume that has not mounted yet, or a working copy whose files are still arriving, is not an unusual Tuesday.</p>
<h2 id="not-being-able-to-tell-is-a-reason-to-keep-everything">Not being able to tell is a reason to keep everything</h2>
<p>That sentence is the fix, and it now runs in two tiers.</p>
<p>If the content directory is not there — or if the archive reads as empty while the previous files held anything at all — nothing is narrowed, and the run says why.</p>
<p>And if only part of the archive is unreadable, a post file that will not parse this minute, a half-written save, a cloud copy still arriving, nothing is narrowed either. Otherwise that gap would be read as a post that was deleted, and its approved discussion deleted along with it. Only a run that could read the whole archive is allowed to decide that something is gone.</p>
<h2 id="the-same-mistake-one-job-over">The same mistake, one job over</h2>
<p>The scheduled-publish cron had a sibling of this. It took a snapshot of the due posts when it started and wrote that snapshot back when it finished, so everything that happened in between was overwritten. An edit made while it worked was silently replaced with the older text. A post deleted in the meantime was recreated from the snapshot, published, and announced to a timeline that cannot be recalled.</p>
<p>Each post is re-read at the moment it is written now.</p>
<h2 id="why-these-two-are-worth-a-post">Why these two are worth a post</h2>
<p>Neither of them is a crash. Both jobs ran, both exited 0, both reported success, and one of them was announcing things to the world while it did.</p>
<p>That is the failure worth being afraid of in a tool that runs unattended: not the job that stops, but the job that carries on confidently with a wrong idea of what your archive contains. A crash you find out about. This kind you find out about when somebody asks where their comment went.</p>]]></description>
  <category>archive</category><category>philosophy</category>
</item>
<item>
  <title>Posts nobody meant to publish</title>
  <link>https://blogsh.app/posts/2026/posts-nobody-meant-to-publish/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/posts-nobody-meant-to-publish/</guid>
  <pubDate>Tue, 01 Sep 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>An importer&#39;s worst failure is not losing a post. It is publishing one.</p>
<p>1.5 went through all eleven import adapters and the machinery underneath them looking for exactly that, and found it three times over, in three different files, made the same way each time.</p>
<h2 id="an-archive-is-the-whole-account">An archive is the whole account</h2>
<p>A Mastodon export is not your public timeline. The outbox holds your followers-only posts and your direct messages beside the public ones — and every standalone toot in it was being written as published. On the archive this was measured against that is 141 posts of 2,548, of which 132 are direct messages, each given its own page, its own sitemap entry and its own item in the feed. Nothing in the run&#39;s summary mentioned it.</p>
<p>The scope was in the export all along, exactly where ActivityPub puts it. Public in <code>to</code> is public. Public in <code>cc</code> is unlisted — a real address, out of the listings. Followers-only and direct become drafts: that keeps the author&#39;s words in the archive and off the site, which is the only reading of &quot;not public&quot; that cannot publish something by accident.</p>
<h2 id="gated-at-serve-time-is-not-gated">Gated at serve time is not gated</h2>
<p>Ghost ships the full body of every members-only and paid post in its export, because a Ghost site decides who you are when it serves the page. Read by anything that does not make that check, every word anybody paid for is plain text sitting in a file.</p>
<p>They arrived published, untagged and uncounted. They now arrive as drafts, tagged, and counted in the summary — the reversible half of the decision, and a number the person running the import can act on.</p>
<h2 id="a-comment-on-the-wrong-line">A comment on the wrong line</h2>
<p>Hugo&#39;s TOML front matter says <code>draft = true</code>. Written as <code>draft = true # nekdy pozdeji</code> — a note to yourself, on precisely the line where a person writes one — the value scanner took the comment as part of the value. That made the flag a truthy string rather than the boolean it looks like, and a truthy string is not <code>true</code>, so the post imported as published.</p>
<p>Somebody&#39;s unfinished writing went onto the open web because they annotated the flag that was supposed to hold it back.</p>
<h2 id="the-rule-underneath">The rule underneath</h2>
<p>All three are one mistake wearing different clothes: the importer treated anything it could not classify as public. The rule runs the other way now. Not being able to tell whether something is publishable is a reason not to publish it — and to say so in the summary, with a count.</p>
<p>An import you have to audit afterwards is one you cannot trust. The point of a number in the summary is that you should not have to go looking.</p>]]></description>
  <category>migration</category><category>philosophy</category>
</item>
<item>
  <title>Cut before it is written</title>
  <link>https://blogsh.app/posts/2026/cut-before-it-is-written/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/cut-before-it-is-written/</guid>
  <pubDate>Mon, 31 Aug 2026 16:12:00 +0200</pubDate>
  <description><![CDATA[<p>A listing card used to be the whole post. The engine rendered every block of it into the page, and the stylesheet clipped the result at 500 pixels.</p>
<p>On the real front page that meant fourteen cards carrying between 794 and 2,616 pixels of content and showing 500. Of the page&#39;s 50,659 characters, 49,312 sat inside the clip. Thirty-three of its 154 focusable elements could be reached with the keyboard while being invisible on screen — links a sighted mouse user could not see and a keyboard user could not skip.</p>
<p>1.5 cuts the card before it is written. The stylesheet hides nothing.</p>
<p>That page went from 89,268 bytes of HTML to 41,155. Its largest card, 7,463 characters, is now 2,566. And &quot;read more&quot; appears exactly when something did not fit, rather than sitting under a card that was already showing everything it had.</p>
<h2 id="the-budget-is-in-pixels-not-characters">The budget is in pixels, not characters</h2>
<p>This is the part that had to be measured. A character budget is the obvious design and it is wrong, because characters do not measure height. A picture costs no characters and several hundred pixels. Written as a rule of &quot;400 characters&quot;, the cut made 34 % of this archive&#39;s cards <i>taller</i> than the clip it was replacing — one of them 4,928 pixels tall.</p>
<p>So the budget is an estimated height, read off a real card: a 534-pixel content column, a 24-pixel line, about 78 characters to a full line, checked against twelve real paragraphs — eleven of which agreed. The estimate is approximate on purpose. The reader&#39;s column is narrower or wider than the nominal one, and so was the 500-pixel clip it replaces. What matters is that the cut lands between blocks rather than through one.</p>
<h2 id="what-is-never-cut">What is never cut</h2>
<p>A picture. If the first block is a photograph it is kept whatever its height, because on a photo post the photograph <i>is</i> the post, and a card that opens with a cropped image is worse than a card that runs long.</p>
<p>A block made of rows — a hundred lines of shell, a long conversation, a list, a table — is cut to the rows that fit, at a row boundary. A paragraph that runs past the budget is cut on a word, with its formatting cut to match, so a link cannot end up pointing past the text it decorates.</p>
<h2 id="and-an-author-can-overrule-all-of-it">And an author can overrule all of it</h2>
<p>A line reading <code>//--more--//</code> splits a post into what it says about itself and what it actually says. The listing card, the link card and the announcement take the first half; the post&#39;s own page shows everything.</p>
<p>That one was asked for in issue #35 by somebody else running the engine, and their complaint was exact: a machine cut rarely lands on a good sentence, and the author knows where theirs is. A height budget is a decent guess about a card. It is not a claim to know where your post stops being an invitation.</p>]]></description>
  <category>build</category><category>content</category>
</item>
<item>
  <title>Two pages the site built out of itself</title>
  <link>https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/</guid>
  <pubDate>Mon, 31 Aug 2026 10:11:00 +0200</pubDate>
  <description><![CDATA[<p>Until now this engine could show you ten posts at a time and nothing else. <code>/page/128/</code> is a real address on a large archive, and it tells you nothing about whether you have landed in 2009 or in 2014 — pagination is anchored from the oldest post, so the number moves under you every time something is published. The only complete list of anything lived in the terminal.</p>
<p>1.5 adds two pages that read the archive instead of walking it.</p>
<h2 id="archive-the-shape-of-the-whole-thing">/archive/ — the shape of the whole thing</h2>
<p>A row per year, with a strip of twelve months beside it. A month is shaded in one of four steps rather than simply lit or dark, because &quot;has posts / has none&quot; is not the question worth answering: on the reference archive a month holds anywhere from one post to eighty-seven, and drawing those the same would hide the one thing a map is for. <code>/archive/&lt;year&gt;/</code> is one line per post, by month.</p>
<p>Two levels and no more. A third would be some 280 pages of individual months — a great deal of building for a question nobody asked.</p>
<p>It is an index, not another listing: no excerpts, no pictures, no cards. And it is cheap by construction. Publishing a post rewrites the map and the current year; 2014 has not changed since new year&#39;s eve 2014 and never will, so a deploy that compares content has nothing to upload for it, ever again.</p>
<figure><img src="https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/01.png" width="1280" height="1390" alt="The archive map of sean.cz: a row for each year from 2026 down to 2003, each with its post count and a strip of twelve shaded months" loading="lazy" decoding="async"><figcaption>Twenty-four years on one page: 6,625 posts, from a year that holds 23 of them to a year that holds 641.</figcaption></figure>
<h2 id="tag-every-subject-the-site-has">/tag/ — every subject the site has</h2>
<p>The engine has always built a page per tag and never anything that showed them all. A site&#39;s own list of what it writes about lived in <code>browse</code>, or in the top eight of <code>stats</code>.</p>
<p>The list is sorted by the folded name, and that is not a detail. Ruby sorts strings by bytes, which puts every accented tag after z: on one real archive that is fifty-two of them, and the last six in byte order are <code>školení</code>, <code>školitel</code>, <code>šumava</code>, <code>švihov</code>, <code>želnava</code>, <code>životvkorporátu</code>. A reader looking for one of those between <code>sirky</code> and <code>sport</code> would not find it. <code>stats</code> folds now too — one question should not have two answers.</p>
<p>Each tag is a pill with its count riding inside it as a superscript, so a line that wraps cannot strand a number beside the wrong name. A reader can switch the order to by-count and the choice is remembered. But the page is <i>built</i> alphabetically and the switch is a script on top of it: a reader whose browser never runs that script gets the order the markup already holds, rather than a control that does nothing.</p>
<figure><img src="https://blogsh.app/posts/2026/two-pages-the-site-built-out-of-itself/02.png" width="1280" height="1410" alt="The tag index of sean.cz: tags drawn as pills in wrapped rows under letter headings, each with its post count in superscript" loading="lazy" decoding="async"><figcaption>846 tags under 27 letters — and čarodějnice, česká televize and české dráhy under C rather than after z, which is what the folded sort buys.</figcaption></figure>
<h2 id="nothing-to-configure">Nothing to configure</h2>
<p>Both pages are made out of the posts. There is no list to maintain, no front matter to add, and nothing that can drift out of step with what the site actually holds.</p>
<p>Every tag that has a page appears and no others. A tag carried only by a draft, a page or an unlisted post is drawn under its post as a flat pill with no link — listing it here would point a reader at a 404, and an index that lies about one address is an index you have to check.</p>]]></description>
  <category>archive</category><category>build</category>
</item>
<item>
  <title>A slug is an address, not a name</title>
  <link>https://blogsh.app/posts/2026/a-slug-is-an-address-not-a-name/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-slug-is-an-address-not-a-name/</guid>
  <pubDate>Sun, 30 Aug 2026 19:54:18 +0200</pubDate>
  <description><![CDATA[<p>For most of this project&#39;s life, a post without a title had two names. Its own page called it by its date. Everything else — the browser tab, the link card somebody shares, every item in the feed — called it <code>burtiky-opekame-hipstamatic-oggl-jane</code>.</p>
<p>That is not a corner case. On the archive this engine is measured against, 2,754 posts of 4,418 carry no title. Sixty-two per cent of a site, going out into the world under an address.</p>
<h2 id="a-slug-is-made-out-of-the-words-it-is-not-the-words">A slug is made out of the words. It is not the words.</h2>
<p>A slug is a machine&#39;s answer to &quot;where does this live&quot;: lowercased, stripped of diacritics, joined with hyphens, cut to the first eight words. Every property that makes it a good address makes it a bad name. It has no capitals, no punctuation, and no idea where the sentence it was cut from was going.</p>
<p>1.5 takes the name from the same place the slug came from — the post&#39;s own opening — and stops in a better place.</p>
<h2 id="where-it-stops">Where it stops</h2>
<p>A whole first sentence wins when one fits a window of four to twelve words, because a name that ends where the writer ended reads like a name. Failing that, the first eight words, which is exactly what slugs have always been cut to, so a post&#39;s name and its address stay recognisably the same post.</p>
<p>The sentence window is there for a reason a Czech archive teaches quickly. A Czech sentence puts its verb and its object at the end, so eight words usually stop just before the point. Where the sentence is short enough to take whole, taking it whole is the better cut.</p>
<p>One cut, not two. When the eighth word is a preposition or a conjunction it is dropped — &quot;…vyplynulo, že&quot; reads as a mistake where &quot;…vyplynulo,&quot; reads as an interruption — and the dropped word then opens the rest of the text rather than disappearing between the halves. Whatever is on either side of a cut has to add back up to what was there.</p>
<h2 id="what-does-not-change">What does not change</h2>
<p>The heading on the post&#39;s own page is still the date it has been since 1.3. A post that never claimed a title is not handed one where its author would see it; it is handed one where a stranger has to recognise it — the tab, the card, the feed, the announcement.</p>
<p>And a post with a real title is untouched. This only ever fills a hole.</p>]]></description>
  <category>content</category><category>archive</category>
</item>
<item>
  <title>./blog.sh 1.5</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-5/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-5/</guid>
  <pubDate>Sun, 30 Aug 2026 08:58:55 +0200</pubDate>
  <description><![CDATA[<p><b>1.5 is the release where the site learned to say what it holds.</b> <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.5">The release is on GitHub</a>.</p>
<p>A post that never had a title used to be called by its address — on this project&#39;s own reference archive that was 62 % of it. Now it is called by its own opening words, everywhere except its heading, which stays the date it has been since 1.3.</p>
<p>Two new pages come with it: <code>/archive/</code>, a map of the whole site in two levels, and <code>/tag/</code>, every subject it has ever written about. Both are built from the posts themselves — nothing to configure and nothing to keep up to date.</p>
<p>Two things a post can now decide for itself: where its teaser stops (<code>//--more--//</code>, asked for in issue #35), and how much of it a listing card shows — cards are cut before they are written rather than drawn in full and hidden with CSS. And a code block carries a copy button, asked for by an operator running a blog of terminal how-tos.</p>
<p>Underneath, four days of adversarial review: two bug bounties, 237 confirmed findings, every one closed and pinned by a test that fails on the old code — or written down with the number of posts it actually affects, which for a third of them turned out to be zero.</p>
<p>Full notes → <a href="https://github.com/DanielSnor/blog.sh/blob/main/CHANGELOG.md">CHANGELOG.md</a></p>]]></description>
  <category>release</category>
</item>
<item>
  <title>Tested like an enemy</title>
  <link>https://blogsh.app/posts/2026/tested-like-an-enemy/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/tested-like-an-enemy/</guid>
  <pubDate>Fri, 28 Aug 2026 16:52:00 +0200</pubDate>
  <description><![CDATA[<p>1.4 went through four adversarial audit rounds before the tag: fleets of independent reviewers over real archives — real exports, a real 4400-post site, real deploy targets — each told to break the engine rather than confirm it. The findings numbered over a hundred. Every one was reproduced before it was fixed, and every fix is pinned by a test that demonstrably fails on the code it fixed.</p>
<h2 id="the-pattern-that-kept-repeating">The pattern that kept repeating</h2>
<p>The uncomfortable lesson: each round&#39;s fixes were where the next round found its bugs. A fix for a comment-parsing rule deleted a hundred lines of somebody&#39;s config; a fix for that hid a widget&#39;s answers; the recovery advice written to save posts could destroy one. Four times in a row, the newest code was the most dangerous code in the tree — which is exactly why the last round audited nothing but the fixes themselves. It caught three regressions of mine before any user could.</p>
<h2 id="eighteen-files">Eighteen files</h2>
<p>The proof I trust most is the quietest one. The whole release, rebuilt over a real 4400-post archive and compared byte for byte against 1.3.2, changes eighteen files: one stylesheet, two scripts, and one line in each draft preview. Published posts do not move by a byte. A release can rework the machinery this much and still owe its archive that kind of stillness — that is the contract, and now there is a diff that shows it being kept.</p>
<h2 id="what-you-get-out-of-it">What you get out of it</h2>
<p>Not a promise of zero bugs — nobody honest sells that. The promise is narrower and better: the failure modes that stay are the loud kind. <code>check</code> refuses what the build refuses. A crash cannot cost a post. A deploy pointed somewhere new uploads everything rather than trusting old records. When the next bug arrives, it should have to announce itself.</p>]]></description>
  <category>philosophy</category>
</item>
<item>
  <title>What a crash may cost</title>
  <link>https://blogsh.app/posts/2026/what-a-crash-may-cost/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/what-a-crash-may-cost/</guid>
  <pubDate>Fri, 28 Aug 2026 10:51:00 +0200</pubDate>
  <description><![CDATA[<p>The queue can now swap two scheduled posts that share a slug across two years — with their media and their edit history riding along, because those are keyed by year and slug exactly like the post file. That sentence is short; making it true was most of a release cycle. The interesting part is not the swap. It is what happens when the power goes out in the middle of one.</p>
<h2 id="the-budget-is-zero">The budget is zero</h2>
<p>A move is several renames, and a crash can land between any two of them. The rule 1.4 holds to: whatever the moment, a crash may cost you the <i>move</i> — never a post. Files step aside under parking names before anything writes; a failed write puts everything back, byte for byte; a hard kill that strands a parked file leaves it where <code>check</code> finds it and says truthfully what it is.</p>
<h2 id="the-advice-is-part-of-the-system">The advice is part of the system</h2>
<p>That last piece turned out to be the hardest. An early version of check&#39;s advice said &quot;this is a stale copy — delete it&quot;, from nothing more than a file existing at the parked file&#39;s old name. But parking only happens when two posts share a slug, so the file standing there is by construction a <i>different</i> post — and after the right crash, the parked file is the only copy of yours there is. The advice now rests on the parked post&#39;s own identity: proven-stale names where the live copy sits; unproven says plainly that this may be the only copy, compare before you touch it, and never delete.</p>
<p>A recovery instruction that can destroy what it recovers is a bug like any other. It just took an audit aimed at our own fixes to see it.</p>]]></description>
  <category>archive</category><category>philosophy</category>
</item>
<item>
  <title>The other half of check</title>
  <link>https://blogsh.app/posts/2026/the-other-half-of-check/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-other-half-of-check/</guid>
  <pubDate>Thu, 27 Aug 2026 16:42:00 +0200</pubDate>
  <description><![CDATA[<p><code>./blog.sh check</code> reads the whole archive and says what is wrong: media a post asks for and hasn&#39;t got, links to addresses nothing answers at, two posts that would be served at one address. Through 1.3 that was where it ended — a list of findings and an exit code, with the fixing left to you and a text editor.</p>
<h2 id="repair-walks-it-with-you">--repair walks it with you</h2>
<p><code>check --repair</code> is the checker&#39;s other half: for each finding it offers the one repair that finding allows — an old address written into the target post&#39;s <code>redirect_from</code>, a relative link rewritten to the address it means, an orphaned file moved to the trash. Nothing is applied without a keypress, nothing is deleted outright, and a finding with no obvious answer — two posts colliding, an image only a human can judge — is shown and passed over rather than guessed at.</p>
<p>It proved itself before it shipped: on a real archive it took 238 findings down to 42, and the 21 repairs it proposed matched what a person had already decided by hand, one for one.</p>
<h2 id="json-says-everything">--json says everything</h2>
<p>The screen shows twenty of a kind and totals the rest — right for reading, useless for a script. <code>check --json</code> prints every finding, uncapped, each with its kind and its data, so anything that wants to act — a migration script, a cron report — works from the same facts the screen summarizes.</p>
<h2 id="and-check-itself-got-stricter">And check itself got stricter</h2>
<p>The rule behind the new findings is one sentence: an archive check calls sound must be one the build will run on. A file that will not parse, a date nothing can read, text that is not a list of blocks, a slug that is not one path segment — each is now a finding, because each is a build that stops. The one tool whose job is to say otherwise no longer says &quot;sound&quot; about any of them.</p>]]></description>
  <category>archive</category><category>build</category>
</item>
<item>
  <title>Deploy in your own language</title>
  <link>https://blogsh.app/posts/2026/deploy-in-your-own-language/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/deploy-in-your-own-language/</guid>
  <pubDate>Thu, 27 Aug 2026 10:41:00 +0200</pubDate>
  <description><![CDATA[<p>The wizards were translated from the start; the narration around them was not. A Czech site watched its own deploy go by in English, one line above a Czech sentence — build warnings, announcement failures, import errors, the queue&#39;s repair instructions, all of it. Not broken, exactly. Just a house where half the rooms answer in a different language.</p>
<h2 id="sixty-sentences-moved-home">Sixty sentences moved home</h2>
<p>1.4 finishes the job: the deploy&#39;s whole narration — header, progress, the closing tally, every degradation notice — plus all eleven build warnings, every Bluesky and Mastodon failure, and the sentences an import prints when a source dies. English, Czech and German carry the same set, and the suite enforces the parity, so a key cannot exist in one language and abort in another.</p>
<h2 id="what-stays-as-it-arrived">What stays as it arrived</h2>
<p>The diagnostics inside the sentences do not get translated: an HTTP code, a server&#39;s raw answer, an exception&#39;s text. Those are evidence, and evidence is quoted, not paraphrased — the sentence around them is the site&#39;s, the thing inside it is the network&#39;s.</p>
<h2 id="the-plural-trap">The plural trap</h2>
<p>&quot;Built 1 posts&quot; is the kind of bug a translation multiplies — Czech declines counts three ways, German differently again. The counted lines are written label-then-number now (<code>posts: 1</code>), which no language has to decline. Boring is correct, and correct in all three at once.</p>]]></description>
  <category>deploy</category><category>build</category>
</item>
<item>
  <title>The same language, spoken differently</title>
  <link>https://blogsh.app/posts/2026/the-same-language-spoken-differently/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-same-language-spoken-differently/</guid>
  <pubDate>Wed, 26 Aug 2026 16:32:00 +0200</pubDate>
  <description><![CDATA[<p>Comments here are replies to a toot, and GoToSocial speaks Mastodon&#39;s API — so comments on a GTS instance should have simply worked. They did not, not once, and nobody saw it fail: arch-linux.cz, the first site to pair this engine with its own GTS instance, had never shown a single comment under any post. My own tests had Mastodon on both ends, so they could never have caught it.</p>
<h2 id="dialect-is-not-language">Dialect is not language</h2>
<p>Two accents were in the way. GoToSocial writes a status address in its own shape, and the engine&#39;s pattern insisted on Mastodon&#39;s — a numeric id where GTS answers with a 26-character ULID. And GTS requires a token even to read a public thread, where Mastodon hands it to anyone who asks. The first accent broke the address parsing; the second broke the entire idea of live comments, which is a visitor&#39;s browser fetching the thread — a browser you cannot give a token to.</p>
<h2 id="moderation-is-the-door-in">Moderation is the door in</h2>
<p>So on GoToSocial, comments are the moderated kind by construction: <code>comments.approval: fav</code>, and the cron reads the thread with the token, server-side, publishing the replies you star. The side effect is one Mastodon sites choose deliberately: visitors&#39; browsers stop contacting your instance at all.</p>
<p><code>doctor --online</code> now tests the capability, not the brand — can an anonymous request read the thread? — so a Mastodon instance in secure mode gets the same honest answer. And the fix was confirmed where the bug was found: the first starred reply on arch-linux.cz appeared under its post the same day 1.4 landed there.</p>]]></description>
  <category>comments</category>
</item>

  </channel>
</rss>
