<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>release – ./blog.sh</title>
    <link>https://blogsh.app/cs/tag/release/</link>
    <atom:link href="https://blogsh.app/cs/tag/release/rss.xml" rel="self" type="application/rss+xml" />
    <description>Příspěvky se štítkem release na osobním webu Daniel Šnor.</description>
    <language>cs</language>
    <lastBuildDate>Mon, 14 Sep 2026 10:10:04 +0200</lastBuildDate>
    <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>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>./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>./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>./blog.sh 1.4</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-4/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-4/</guid>
  <pubDate>Tue, 25 Aug 2026 16:22:00 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.4 is out — <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.4">release on GitHub</a>.</p>
<p>The widest release since 1.0. Three things arrived: the commits widget reads Gitea and Forgejo — asked for by one of the first people outside this project to run the engine, because most of the Fediverse keeps its code on Codeberg, and mirroring to GitHub for one sidebar card was the workaround. <code>widgets.commits.instance</code> takes the server&#39;s address; that is the whole configuration. Comments work on GoToSocial — found on arch-linux.cz, where no post had ever shown a single comment, silently; my own tests had Mastodon on both ends, so they could never have caught it. And the engine now speaks the site&#39;s language everywhere: a Czech or German site used to watch its own deploy and build warnings go by in English, one line above a translated sentence. Sixty-odd sentences moved into the locales.</p>
<p>Under those sits the real work of this release: four adversarial audit rounds over real archives, the last one aimed at the fixes themselves — which is how it caught three of my own regressions before anyone else could. What came out of it is a principle more than a list: nothing fails silently. <code>check</code> refuses everything the build refuses. A crash mid-way through a queue move cannot cost a post. An install in a folder called <code>blog [1]</code> builds the site instead of silently publishing an empty one. An imported <code>javascript:</code> link becomes a dead anchor. A deploy pointed at a new server uploads everything instead of trusting the old server&#39;s records.</p>
<p>Nothing to migrate — <code>git pull</code>, rebuild, deploy. Expect a modest one: on my own 4400-post archive, eighteen files change — the stylesheet, two scripts, and one line in each draft preview whose tags have no listing page yet. Published posts do not move by a byte.</p>
<p>Full notes → <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.4">release</a></p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.3.2</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-3-2/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-3-2/</guid>
  <pubDate>Fri, 21 Aug 2026 18:32:45 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.3.2 is out — <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.3.2">release on GitHub</a>.</p>
<p>A GoToSocial release, and both fixes came from the first site to wire blog.sh to a GoToSocial instance: arch-linux.cz runs its own, instead of pointing at a Mastodon account. My own tests had Mastodon on both ends, so they could never have found either.</p>
<p>GTS speaks Mastodon&#39;s API with its own accents, and two places in the engine had taken the Mastodon dialect for the whole language: the feed widget asked every host for JSON first and got exactly that, a JSON Feed the XML parser then reported as malformed; and <code>doctor</code> refused the account id its own setup had pointed you at, because it demanded digits where GTS answers with a 26-character ULID.</p>
<p>One small thing for skins came from the same site: a listing page now says whether it is the first one. <code>&lt;body&gt;</code> carries <code>page-first</code> or <code>page-cont</code>, so a stylesheet can tell the front of a listing from its <code>/page/N/</code> continuations — something CSS cannot work out on its own, because it cannot read an address.</p>
<p>Nothing to migrate — <code>git pull</code>, rebuild, deploy. Expect one full-sized deploy: every listing page changes by those few bytes, and nothing else moves. <a href="https://github.com/DanielSnor/blog.sh/blob/main/CHANGELOG.md">Full notes →</a></p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.3.1</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-3-1/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-3-1/</guid>
  <pubDate>Thu, 20 Aug 2026 16:31:10 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.3.1 is out — <a href="https://github.com/DanielSnor/blog.sh/releases/tag/v1.3.1">release on GitHub</a>.</p>
<p>A bug-fix release about restraint. The scheduled-publish cron now declines the announcements it cannot take back: a backdated post publishes without announcing, and a post that already carries its announcement address is never announced again — not by the cron, not by publish, not by <code>toot</code>, whichever network the address lives on. The queue takes its lock for every write too, so a tick landing mid-edit cannot revert a post it just published.</p>
<p>The first hour of a fresh install stopped misleading: preview and publish print the address that actually opens instead of <code>example.com</code>, the deploy speaks the site&#39;s language, and the example config no longer chooses Mastodon for you — a network is a choice.</p>
<p>And emptiness is an answer now, in both directions: an emptied section takes its heading with it instead of crashing the build or failing <code>doctor</code>, and a heading over content that stays can finally be turned off.</p>
<p>Nothing to migrate — <code>git pull</code>, rebuild, deploy. <a href="https://github.com/DanielSnor/blog.sh/blob/main/CHANGELOG.md">Full notes →</a></p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.3</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-3/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-3/</guid>
  <pubDate>Wed, 19 Aug 2026 06:23:32 +0200</pubDate>
  <description><![CDATA[<p>./blog.sh 1.3 is out.</p>
<p>Dressing a site no longer means editing the engine: your own stylesheet, menu, sidebar and lead images are settings now. Posts group into series, pages step out of the stream, and every edit keeps the version before it.</p>
<p>And three commands that read the whole archive: check reports what&#39;s broken, stats counts what&#39;s there, and export packs it all into Jekyll&#39;s layout. The way out ships with it.</p>
<p>#blogsh #ruby #staticsite</p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.2.1</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-2-1/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-2-1/</guid>
  <pubDate>Wed, 12 Aug 2026 05:46:59 +0000</pubDate>
  <description><![CDATA[<p>./blog.sh 1.2.1 is out.</p>
<p>A phone records where you were standing in every photo it takes. Social networks strip that on upload; a static site has nobody to do it. Now the engine does, on the way into the archive — and the doctor will clean the ones you saved before.</p>
<p>The bio, the footer, and the banner&#39;s claim are now Markdown. They were the only texts on a Markdown blog that had to be HTML.</p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.2</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-2/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-2/</guid>
  <pubDate>Tue, 11 Aug 2026 09:14:34 +0000</pubDate>
  <description><![CDATA[<p>./blog.sh 1.2 is out — the import release.</p>
<p>Eight import sources became twenty-two: every social network you are likely to have posted to, the blog platforms, and the Wayback Machine for blogs whose platform no longer exists.</p>
<p>Setting a site up is a conversation now. So is how it looks, with the candidate palette rendered on your own site before you keep it.</p>
<p>And a screen for walking an archive that got too big to scroll.</p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.1</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-1/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-1/</guid>
  <pubDate>Wed, 05 Aug 2026 19:57:12 +0000</pubDate>
  <description><![CDATA[<p>./blog.sh 1.1 is out.</p>
<p>Pin a post to the top. Schedule into publishing slots that queue instead of colliding. Attach files as download cards. Rename a slug without breaking a link — the old URL redirects forever. One dialogue for everything you can do to a post.</p>
<p>And deploy guards that measure the build against the build, so a failed upload can&#39;t quietly switch them off.</p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.0.1</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-0-1/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-0-1/</guid>
  <pubDate>Sun, 02 Aug 2026 22:07:34 +0000</pubDate>
  <description><![CDATA[<p>./blog.sh 1.0.1 is out — eighteen fixes, none of them reported by a user.</p>
<p>They came out of an independent verification pass over the release: a failed write could leave a post without a file, one bad post could stop the whole build, a missing media file could delete the copy that was already live.</p>
<p>There&#39;s now a CHANGELOG a clone can read, and ./blog.sh version tells you what you&#39;re running.</p>]]></description>
  <category>release</category>
</item>
<item>
  <title>./blog.sh 1.0</title>
  <link>https://blogsh.app/posts/2026/blog-sh-1-0/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-1-0/</guid>
  <pubDate>Sat, 01 Aug 2026 06:34:35 +0000</pubDate>
  <description><![CDATA[<p>./blog.sh 1.0 is out.</p>
<p>A blog engine that&#39;s a folder and a shell script. Ruby stdlib and bash — no database, no gems, no admin. Comments live on the Fediverse. Eight importers, so your Tumblr and Twitter archives have somewhere to go. MIT licensed.</p>
<p>The site you&#39;re reading is the engine running itself.</p>]]></description>
  <category>release</category>
</item>

  </channel>
</rss>
