<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>authoring – ./blog.sh</title>
    <link>https://blogsh.app/tag/authoring/</link>
    <atom:link href="https://blogsh.app/tag/authoring/rss.xml" rel="self" type="application/rss+xml" />
    <description>Posts tagged authoring on Daniel Šnor&#39;s personal site.</description>
    <language>en</language>
    <lastBuildDate>Tue, 08 Sep 2026 10:21:00 +0200</lastBuildDate>
    <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>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>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 link is the player</title>
  <link>https://blogsh.app/posts/2026/the-link-is-the-player/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-link-is-the-player/</guid>
  <pubDate>Tue, 25 Aug 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>Embedding a video usually goes like this. Open the platform, click Share, click Embed, copy the blob of HTML it hands you, paste it into your post. What you have just filed in your own archive is somebody else&#39;s markup: an iframe, sometimes a script, a couple of tracking parameters and a class name from a design system you don&#39;t use. And when the platform changes its embed path — they do — every post you ever wrote that way breaks on the same afternoon.</p>
<p>Here you paste the address you would send a friend.</p>
<h2 id="two-of-them-actually-playing">Two of them, actually playing</h2>
<p>A video, with a caption:</p>
<figure><div class="embed-responsive"><iframe src="https://framatube.org/videos/embed/kkGMgK9ZtnKfYAgnEtQxbv" title="peertube" loading="lazy" frameborder="0" allow="autoplay; clipboard-write; encrypted-media; picture-in-picture" allowfullscreen></iframe></div><figcaption>PeerTube explaining what PeerTube is, on Framasoft&#39;s own instance.</figcaption></figure>
<p>Audio, the same gesture:</p>
<figure><iframe class="embed-audio" src="https://w.soundcloud.com/player/?url=https%3A%2F%2Fsoundcloud.com%2Fnasa%2Fsputnik-beep" title="soundcloud" width="100%" height="166" loading="lazy" frameborder="0" allow="autoplay; clipboard-write; encrypted-media; picture-in-picture" allowfullscreen></iframe><figcaption>Sputnik, October 1957 — the beep that opened the space age.</figcaption></figure>
<p>Two lines of markdown. No embed dialog, no account, no copied code.</p>
<h2 id="what-the-post-actually-stores">What the post actually stores</h2>
<p>Not their HTML. A provider, the identifying part of the address, and — because PeerTube is federated and has no single home — which instance it came from:</p>
<pre class="code-block"><code>&quot;type&quot;: &quot;video&quot;,
&quot;provider&quot;: &quot;peertube&quot;,
&quot;url&quot;: &quot;https://framatube.org/w/kkGMgK9ZtnKfYAgnEtQxbv&quot;,
&quot;embed_id&quot;: &quot;kkGMgK9ZtnKfYAgnEtQxbv&quot;,
&quot;embed_origin&quot;: &quot;https://framatube.org&quot;</code></pre>
<p>The build assembles the player from that. Two things follow. No foreign markup ever enters your archive, so nothing arrives carrying its own tracking. And when a platform moves its embed path, the fix is one edit in the engine, not a re-import of fifteen years of posts.</p>
<p>The page&#39;s own policy follows the same rule. Beyond YouTube&#39;s two origins, which are granted everywhere because an imported embed can point at either of them, a page allows only what its own posts play. This one names <code>framatube.org</code> and <code>w.soundcloud.com</code> — and so does whichever listing page happens to be showing it, because a listing is made of the posts on it. A page with no player in it names neither. A blog with no Spotify in it never mentions Spotify.</p>
<p>Being straight about the limit: the player is still an iframe served by the platform, and the moment a reader presses play, the platform knows. What changes is that your archive doesn&#39;t carry their code, and the rest of your site doesn&#39;t reach for them at all. (YouTube players are built on <code>youtube-nocookie.com</code>, which at least holds the cookies until playback.)</p>
<h2 id="video-audio-and-two-that-have-to-be-asked">Video, audio, and two that have to be asked</h2>
<p>Video: YouTube, Vimeo, PeerTube, archive.org. Audio: Spotify, SoundCloud, Mixcloud. Every one of those is a string transform, so writing a post stays an offline operation.</p>
<p>Funkwhale and Bandcamp couldn&#39;t be. Funkwhale&#39;s obvious embed path builds a player that looks entirely correct and stays a black rectangle forever; Bandcamp&#39;s address contains no id at all, only a slug. Both are asked once, when the post is saved — the only moment writing needs the network — and the answer is stored as an address, so an edit never asks again and the build stays offline. If the lookup fails, you get a sentence and a saved post with a link in it. Never a refused save: writing on a train has to end with a written post.</p>
<h2 id="the-rules-came-from-the-services-not-from-their-docs">The rules came from the services, not from their docs</h2>
<p>Every pattern here carries a correction that no documentation page would have given us. An unlisted Vimeo link needs its hash or the player answers 403. A Spotify address copied out of a browser carries an <code>intl-xx</code> segment the embed path rejects. SoundCloud has no id to extract at all, so the whole address goes to the widget — which is also how a private track&#39;s secret token reaches it. Mixcloud&#39;s widget redirects to a second hostname, and the page&#39;s policy has to allow that one too.</p>
<p>PeerTube is the awkward one, and instructive. Being federated, it has no domain to match against: the shape of the path is the entire signal — a full UUID, or the 22-character short id PeerTube derives from one. Which means a 22-character slug sitting on some other site&#39;s <code>/w/</code> page would be taken for a video as well. That is the residual cost of a platform with no home address, and it fails where you can see it — an empty player, one edit away from being a link again — rather than quietly.</p>
<p>And when an address says nothing the engine recognises, nothing is guessed at. The post keeps the link, and the page says the video is unavailable, politely, instead of drawing an empty frame and hoping.</p>]]></description>
  <category>content</category><category>authoring</category>
</item>
<item>
  <title>Ten versions back</title>
  <link>https://blogsh.app/posts/2026/ten-versions-back/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/ten-versions-back/</guid>
  <pubDate>Fri, 21 Aug 2026 16:52:00 +0200</pubDate>
  <description><![CDATA[<p>The engine has never had an undo. Editing a post rewrote its file, and what the post said an hour ago was gone — not because anything went wrong, but because a save is a save.</p>
<p>Now the previous text is kept before every overwrite, and <code>[v]</code> in a post&#39;s properties puts one back.</p>
<h2 id="before-every-overwrite-not-just-before-an-edit">Before every overwrite, not just before an edit</h2>
<p>A re-import is an overwrite too, and that turns out to be the case where this earns its keep. Editing a post by hand is deliberate; re-importing a source over a post you have since tidied up is the accident that eats an evening&#39;s work, and up to now the only recovery was a backup.</p>
<p>Ten per post, newest first.</p>
<h2 id="picked-by-recognising-it-not-by-reading-a-timestamp">Picked by recognising it, not by reading a timestamp</h2>
<p>The list is one you walk like every other list in the wizard, and the row under the cursor shows what that version said — its title, or its opening words if it hasn&#39;t got one. Ten timestamps tell you nothing about which one is the version you want. Ten opening sentences tell you immediately.</p>
<p>Confirming is one keypress, and it can be that cheap because restoring loses nothing: the current text is kept as a version of its own before it is replaced. A wrong choice is one <code>[v]</code> away from being walked back.</p>
<h2 id="what-it-doesn-t-cover">What it doesn&#39;t cover</h2>
<p><b>Text only.</b> Images are not versioned, and the line above the list says so rather than leaving you to find out. A restore brings back words, and the pictures are whatever the post&#39;s media directory holds now.</p>
<p><b>Ten.</b> The eleventh save pushes the oldest one out. That is a decision about disk rather than about safety — but it did produce a bug worth naming: ten re-imports that changed nothing used to fill all ten slots, quietly pushing out the hand-edited state the versions exist to protect. A version identical to the newest one stored is no longer written.</p>
<p><b>A save always wins.</b> If the versions directory cannot be written — a full disk, the wrong permissions — the copy is skipped and the save goes through anyway, silently. That is deliberate and I would choose it again: the point of this is to lose less, and refusing somebody&#39;s save because the undo could not be filed would lose more. It does mean the undo is a courtesy rather than a guarantee, which is the difference between it and a backup.</p>
<p><b>They live with the content.</b> <code>content.nosync/versions/</code>, beside the posts. They travel with a post into the trash and back out again, and a restore from a backup that copied only <code>posts/</code> brings back the archive and loses the undo. The backup list in the docs says which paths matter; this is a new line on it.</p>
<h2 id="the-key-appears-when-there-is-something-to-restore">The key appears when there is something to restore</h2>
<p>A post that has never been edited shows no <code>[v]</code>, because an undo with nothing behind it is a key that teaches you not to trust the keys.</p>]]></description>
  <category>authoring</category>
</item>
<item>
  <title>Walking the archive</title>
  <link>https://blogsh.app/posts/2026/walking-the-archive/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/walking-the-archive/</guid>
  <pubDate>Fri, 14 Aug 2026 10:51:00 +0200</pubDate>
  <description><![CDATA[<p>A post here recently argued that the wizard menu got shorter because operations on a post belong <i>in</i> the post, not in a menu. That still holds. This release adds a screen anyway, and the reason is worth stating: <b>finding a post is a different job from doing something to one.</b></p>
<p><code>./blog.sh browse</code> shows the same posts <code>list</code> does, as a screen you stay in.</p>
<h2 id="what-it-is">What it is</h2>
<p>Arrows and Page Up/Down through the whole archive. Filters by type, state and tag, with the count next to each. And a search that filters as you type.</p>
<p>The search is the site&#39;s own. Words are ANDed, <code>&quot;a quoted phrase&quot;</code> counts as one, <code>-word</code> excludes, and diacritics never decide a match. The query language and the folding live in <code>lib/search_query.rb</code>, next to a note that it and <code>assets/js/search.js</code> change together — because a query that means one thing in your browser and another in your terminal is worse than having no search in the terminal at all.</p>
<p>It searches the full text of every post, so under the selected row there is a line of that post&#39;s own text showing you <i>why</i> it matched.</p>
<p>Space opens a read-only preview — the same markdown <code>edit</code> would hand you, with media lines shortened to their filenames. Enter opens it for editing and comes back to the same row, with the same filter still applied. <code>z</code> clears the filters.</p>
<h2 id="two-small-decisions-that-took-the-longest">Two small decisions that took the longest</h2>
<p><b>The keys are deliberately none of the letters that mean an action elsewhere.</b> <code>p</code> is &quot;publish&quot; in three dialogs and <code>x</code> is &quot;delete&quot; in two, so neither could be the preview key here — muscle memory does not check which screen you are on. The preview is the space bar, the way every file manager has done it for thirty years.</p>
<p><b>The rows lead with the title, not the slug.</b> <code>list</code> still prints the slug first, because down a pipe the slug is the thing you copy into the next command — and <code>browse</code> falls back to exactly that when it is not talking to a terminal. But on screen, a title is what you are scanning for. On an imported archive that matters more than it sounds: over half the posts arrive with no title at all, so the row falls back to the slug for those, which is precisely the set where the slug is all there is.</p>
<h2 id="who-this-is-actually-for">Who this is actually for</h2>
<p>Twenty-two importers mean archives that are thousands of posts long, not dozens. <code>list</code> was written when this site had fourteen. Scrolling a pipe is fine for fourteen.</p>
<p>For four thousand imported posts, from six platforms, half of them untitled, you need to be able to look — filter to one origin tag, search for a word you half remember, read the matching line, open the one you meant. That is the whole feature, and it only became necessary because the importing worked.</p>]]></description>
  <category>authoring</category><category>content</category>
</item>
<item>
  <title>Your camera writes down where you were</title>
  <link>https://blogsh.app/posts/2026/your-camera-writes-down-where-you-were/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/your-camera-writes-down-where-you-were/</guid>
  <pubDate>Wed, 12 Aug 2026 10:31:00 +0200</pubDate>
  <description><![CDATA[<p>Take a photo on a phone and it records the spot you stood on. Not as a guess — as coordinates, accurate to a few metres, in a block of metadata that travels with the file wherever it goes.</p>
<p>Social networks strip that on upload. They have done it for years, quietly enough that most people have stopped thinking about it. A static site has nobody to do it: <code>blog.sh</code> copied your photo into the archive byte for byte, and nothing in the engine had ever looked at metadata at all. So a picture of a cat on a windowsill published the windowsill.</p>
<p>From 1.2.1 it doesn&#39;t. A photo loses its location on the way into the archive — when you add it to a post, and equally when it arrives through any of the twenty-two importers, because they all end up in the same write.</p>
<h2 id="only-the-location">Only the location</h2>
<p>The camera, the lens, the exposure, the moment the shutter opened: those stay. They are your own record of your own photograph, and an archive that silently threw them away would be answering a question nobody asked.</p>
<p>One tag matters more than the rest, and it stays too: <b>Orientation</b>. A phone held upright stores the picture sideways and writes down that it should be turned. Delete that tag and every portrait photo on your site lies on its back. Turning the pixels themselves would need an image decoder, and <code>blog.sh</code> runs on the Ruby standard library and nothing else — no gems, by design. Keeping the one tag that matters is what makes this possible without a dependency.</p>
<p>If your site is the kind that wants the place kept — a walking diary, a photo blog where the map is the point — one line turns it off:</p>
<pre class="code-block"><code class="language-yaml">media:
  strip_location: false</code></pre>
<h2 id="the-photos-you-already-published">The photos you already published</h2>
<p>Those are left alone. An engine upgrade should not go back and rewrite pictures you put on the web years ago, so nothing happens until you ask.</p>
<p>Ask first what there is to ask about:</p>
<pre class="code-block"><code>./blog.sh doctor</code></pre>
<p>It counts the published photos that still carry a location and says so in one line among the rest of the checkup. If the answer is none, you&#39;re done.</p>
<p>If it isn&#39;t, this cleans them:</p>
<pre class="code-block"><code>./blog.sh doctor --strip-location</code></pre>
<p>That is the only thing <code>doctor</code> has ever written rather than reported, which is why it has to be asked for by name. It prints how many it changed, and one thing worth knowing before you run it: every rewritten photo gets a new checksum, so your next deploy uploads all of them again. On a big archive that&#39;s a real transfer, not a rounding error. Then rebuild as usual:</p>
<pre class="code-block"><code>./blog.sh rebuild</code></pre>
<h2 id="what-this-doesn-t-cover">What this doesn&#39;t cover</h2>
<p>Written down rather than left to be discovered, because a privacy feature that quietly misses cases is worse than one whose edges you know:</p>
<ul><li><b>JPEG only.</b> That&#39;s what phones produce, and a HEIC converted by the engine arrives as one. Exif in a PNG or a WebP is left alone.</li><li><b>XMP isn&#39;t read.</b> A file can carry a second copy of the coordinates in an XMP packet, and that copy stays.</li><li>A GPS block holding nothing but its own version number isn&#39;t treated as a location, and the file isn&#39;t rewritten for it. This is more common than it sounds: in one real 2962-photo archive, three photos had exactly that shape — and not one of them had actual coordinates. Counting those would have been a headline made entirely of false alarms.</li></ul>]]></description>
  <category>content</category><category>authoring</category>
</item>
<item>
  <title>Setting up is a conversation now</title>
  <link>https://blogsh.app/posts/2026/setting-up-is-a-conversation-now/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/setting-up-is-a-conversation-now/</guid>
  <pubDate>Tue, 11 Aug 2026 16:22:00 +0200</pubDate>
  <description><![CDATA[<p>Until this release, installing ./blog.sh meant copying two files and editing 277 lines of commented YAML before the first build would run. Everything was documented. That is not the same as being easy.</p>
<p><code>./setup.sh</code> asks instead, and checks every answer as it arrives.</p>
<h2 id="the-questions-that-were-quietly-dangerous">The questions that were quietly dangerous</h2>
<p>Three settings could be typed wrong in a way nothing would ever tell you about.</p>
<p><b>The timezone.</b> A typo here does not fail — Ruby falls back to UTC and says nothing — it just dates every post two hours off, forever. The wizard offers the zone from the machine&#39;s own database, unless the machine sits on UTC, which is a fact about a datacenter rather than about the person answering; then it suggests from the language you are being asked in. And it refuses anything that isn&#39;t a real zone.</p>
<p><b>The address.</b> It lives in two places, and <code>env.sh</code> overrides <code>config/site.yml</code>. The shipped example has it pointing at <code>example.com</code>, so filling in only the config leaves your site calling itself example.com in its feed, its sitemap and every share preview. The wizard writes both.</p>
<p><b>The Mastodon account id.</b> The sidebar widget wants a numeric id and people fill in an <code>@handle</code>, after which it shows nothing at all, quietly. The wizard verifies your token against the instance on the spot — and the id comes back out of that same call, so it can simply be offered, already correct.</p>
<p>Choosing one comments network switches the other off, because a config with both is one the build refuses to load.</p>
<h2 id="nothing-is-written-until-the-end">Nothing is written until the end</h2>
<p>Answers are collected, then both files&#39; diffs are shown with secrets masked, and one confirmation covers the lot. Ctrl-C anywhere leaves an existing install exactly as it was.</p>
<p>Every question can be skipped with Enter. Re-running the wizard is how you change any of this later — and editing the files by hand keeps working exactly as it did. The two are interchangeable in both directions, because a config file you are not allowed to open is not a config file.</p>
<p>The diff shown is a proper LCS diff, incidentally. Comparing line for line failed in a way that mattered: adding one entry to a list shifts every line below it, so a four-line change read as <i>everything from here to the end of the file</i> — precisely the impression a tool asking permission to edit your config must not give.</p>
<h2 id="and-your-comments-survive">And your comments survive</h2>
<p>Both wizards write through a text-level editor that substitutes values into the documented template and leaves every other byte alone. Loading the YAML and dumping it back would have thrown away the two hundred lines of explanation, the commented-out blocks you uncomment when you want a widget, and the folded scalars real sites keep HTML in.</p>
<p>Every write is read back and verified, and restored from its backup if the file does not say what it was asked to say.</p>
<h2 id="the-four-line-start">The four-line start</h2>
<pre class="code-block"><code>git clone https://github.com/DanielSnor/blog.sh.git
cd blog.sh &amp;&amp; ./setup.sh
./blog.sh add
./blog.sh preview</code></pre>
<p>The second line used to be two file copies and an editor. That is the whole change, and it is the one I should have made first.</p>]]></description>
  <category>authoring</category>
</item>
<item>
  <title>The menu got shorter</title>
  <link>https://blogsh.app/posts/2026/the-menu-got-shorter/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-menu-got-shorter/</guid>
  <pubDate>Thu, 06 Aug 2026 18:27:18 +0200</pubDate>
  <description><![CDATA[<p>The wizard used to list ten things you could do. It lists five now, and the engine can do more than it could before.</p>
<p>That&#39;s not a paradox, it&#39;s an admission: eight of those ten were not activities. They were operations on a post, wearing the costume of a menu item.</p>
<h2 id="everything-about-a-post-in-one-place">Everything about a post, in one place</h2>
<p><code>./blog.sh props &lt;slug&gt;</code> — or, in the wizard, pick a post and press <code>v</code> — shows what there is to know about it. State, type, tags, whether it&#39;s pinned, whether it&#39;s been announced and where. If it&#39;s scheduled, the whole publishing queue.</p>
<p>And the actions live there too, next to the facts they act on. A draft offers publish and schedule. A published post offers unpublish, re-announce, pin or unpin, rename, delete.</p>
<p>You no longer go to a menu, choose &quot;unpublish&quot;, and then hunt for the post. You go to the post and see that unpublishing is one of the things it can do right now.</p>
<h2 id="what-stayed-where-it-was">What stayed where it was</h2>
<p>Type and tags are still edited in the frontmatter of <code>edit</code>, prefilled with their current values, because they&#39;re text you write. The pin is a switch rather than a value, so it toggles right in the dialog with <code>c</code> — and the header line keeps working, for anyone who&#39;d rather type it.</p>
<p>The five that remain are the ones that really are activities: write, edit, import, list, rebuild.</p>
<h2 id="one-guard-worth-mentioning">One guard worth mentioning</h2>
<p>The dialog acts on the post it read when it opened. If the scheduled-publish cron fired in between — you opened the properties of a draft, went for coffee, came back at 09:31 — an action taken on that stale copy could have reverted a post the cron had just published, dropping its announcement URL along the way.</p>
<p><code>edit</code> already guarded against exactly that. Now the dialog&#39;s actions do too. It&#39;s the kind of bug you only find by asking &quot;what if something else touched this file while I was looking at it?&quot;, which is a question worth asking of every screen that holds state open.</p>
<h2 id="if-you-script">If you script</h2>
<p>The wizard&#39;s numbering changed with the shorter menu, so a scripted <code>printf &quot;4\n&quot; | ./blog.sh</code> picks something different than it did in 1.0. The CLI commands are the stable interface — none of them changed, and none of them will.</p>]]></description>
  <category>authoring</category>
</item>
<item>
  <title>The photo your iPhone won&#39;t show anyone</title>
  <link>https://blogsh.app/posts/2026/the-photo-your-iphone-wont-show-anyone/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-photo-your-iphone-wont-show-anyone/</guid>
  <pubDate>Thu, 06 Aug 2026 13:52:02 +0200</pubDate>
  <description><![CDATA[<p>An iPhone shoots HEIC by default. Safari renders it. Chrome doesn&#39;t. Firefox doesn&#39;t. So a photo straight off the phone, attached to a post and published, is a photo most of your readers will never see.</p>
<p>The old behaviour was the worst possible one: the file went in, the build couldn&#39;t measure it, and the image quietly vanished from the page — taking its caption with it. No error. Just a post with a hole where a photo was.</p>
<h2 id="refused-with-the-command-you-need">Refused, with the command you need</h2>
<p>Attaching a HEIC now stops the save and prints the exact conversion command for the machine you&#39;re standing at:</p>
<pre class="code-block"><code>sips -s format jpeg photo.heic --out photo.jpg      # macOS
heif-convert photo.heic photo.jpg                    # Linux</code></pre>
<p>Your file stays exactly where it was, in <code>incoming/</code>. Nothing is deleted, nothing is guessed at. You convert it and carry on.</p>
<h2 id="or-converted-if-you-ask">Or converted, if you ask</h2>
<p>Set <code>media.convert_heic: true</code> and the engine converts it for you, using whichever tool it finds — <code>sips</code>, <code>heif-convert</code>, <code>magick</code>, <code>vips</code>. If it finds none, it falls back to the refusal above rather than pretending.</p>
<p>That&#39;s off by default on purpose. Converting means the file on your site isn&#39;t the file you handed over, and that should be a decision you made, not a thing that happened to you.</p>
<h2 id="detection-is-by-content">Detection is by content</h2>
<p>Renaming <code>photo.heic</code> to <code>photo.jpg</code> doesn&#39;t get it past the check — the engine reads what the file actually is, not what it&#39;s called. Phones and export tools mislabel files often enough that trusting the extension would just move the silent failure somewhere less obvious.</p>
<h2 id="the-rule-underneath">The rule underneath</h2>
<p>An image must never disappear quietly. If the engine can&#39;t handle a file, it says so, names the file, and leaves it where it is. A build that drops a photo and finishes with a cheerful summary has lied to you, and you&#39;ll find out months later when someone mentions the empty space.</p>
<p>(If you&#39;d rather sidestep all of this: Settings → Camera → Formats → Most Compatible makes the phone shoot JPEG.)</p>]]></description>
  <category>content</category><category>authoring</category>
</item>
<item>
  <title>Three drafts, three mornings</title>
  <link>https://blogsh.app/posts/2026/three-drafts-three-mornings/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/three-drafts-three-mornings/</guid>
  <pubDate>Thu, 06 Aug 2026 06:23:56 +0200</pubDate>
  <description><![CDATA[<p>Writing doesn&#39;t arrive evenly. You get an evening where three posts want out, then nothing for a week. Publishing them the way they were written means three posts in one night and silence afterwards — which is a worse blog than the same three posts spread across three mornings.</p>
<p>So a site can now say when it usually publishes:</p>
<pre class="code-block"><code>publishing:
  slots:
    - &quot;mon 09:30&quot;
    - &quot;wed 09:30&quot;
    - &quot;fri 09:30&quot;</code></pre>
<p>Or, if you write more than that, a single <code>&quot;daily 09:00&quot;</code>.</p>
<p>With that in the config, scheduling a draft offers you the next slot no other scheduled post already occupies. Three drafts written in one evening are offered Monday, Wednesday and Friday, in that order, without you counting days.</p>
<h2 id="it-only-ever-offers">It only ever offers</h2>
<p>This is the part I care about most, because a queue that takes decisions away from you is a queue you fight.</p>
<p>Typing a date overrides the offer, always. A post you hand-schedule for 14:17 on a Tuesday blocks nobody — it isn&#39;t in a slot, so the queue routes around it. And nothing ever moves a post that already has a time. The slots suggest; you decide.</p>
<p>Without the key in your config, the prompt is exactly the one that was there in 1.0.</p>
<h2 id="the-offer-explains-itself">The offer explains itself</h2>
<p>An offer of Sunday, on a site with a Saturday slot, reads like a queue that skips Saturdays. So the offer names the slots it walked past and who is holding them:</p>
<pre class="code-block"><code>next free slot: Sun 09:30
  Sat 09:30 taken by &quot;the-post-that-stays&quot;</code></pre>
<p>A scheduled draft&#39;s properties print the whole queue for the same reason. A queue you can&#39;t see is a queue you don&#39;t trust.</p>
<h2 id="the-unglamorous-half">The unglamorous half</h2>
<p>Two drafts landing in the same slot would be worse than no queue at all, so the slot search skips anything already claimed — and it does that across a daylight-saving change too, which is where the first version double-booked. Clocks that move an hour twice a year are the reason scheduling code is never as short as it looks.</p>]]></description>
  <category>authoring</category><category>content</category>
</item>
<item>
  <title>Some posts are downloads</title>
  <link>https://blogsh.app/posts/2026/some-posts-are-downloads/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/some-posts-are-downloads/</guid>
  <pubDate>Wed, 05 Aug 2026 23:30:17 +0200</pubDate>
  <description><![CDATA[<p>A photo in a post is a bare filename in an image line. It always has been:</p>
<pre class="code-block"><code>![A view from the window](window.jpg)</code></pre>
<p>So a file attached to a post is the same line without the exclamation mark:</p>
<pre class="code-block"><code>[Reading notes, 2025](reading-notes.pdf)</code></pre>
<p>A whole line that is nothing but a link, pointing at a bare filename with a known extension, is an attachment. It gets staged through <code>incoming/</code> like a photo, stored next to the post like a photo, and it never leaves your site.</p>
<p>A line with a URL in it stays what it has always been: a link. The difference is the bare filename — the same rule photos already follow, so there&#39;s nothing new to learn.</p>
<h2 id="rendered-as-a-card-not-a-link">Rendered as a card, not a link</h2>
<p>An attachment renders as a card carrying the label, the extension and the size. The size is there because a download deserves to say what it costs before you commit to it — a 40 MB PDF on a phone on mobile data is a different proposition from a 200 kB one, and the reader should get to make that call.</p>
<p>Over about 50 MB you get a warning when you save. Not a refusal — sometimes a big file is exactly the point — just a note that you&#39;re about to ask a lot of somebody.</p>
<p>The extensions that count: <code>.pdf</code>, <code>.zip</code>, <code>.tgz</code>, <code>.epub</code>, <code>.txt</code>, <code>.md</code>, <code>.ics</code>, <code>.gpx</code>, <code>.csv</code>. A whitelist, because the engine should only publish files it can honestly describe.</p>
<h2 id="and-a-new-kind-of-post">And a new kind of post</h2>
<p>A short line plus a file makes the post a <i>document</i> — the same way a short caption plus a photo makes an image post. Write more than a caption and it&#39;s an article that happens to have an attachment.</p>
<p>The first document you publish makes DOCUMENTS appear in the navigation. Nothing to configure: the menu has shown only the types a site actually has since 1.0, so the section arrives with its first tenant and would leave with its last.</p>
<p>Publish and forget. That was the whole brief: a PDF, a GPX track, a calendar file — things worth putting somewhere permanent without writing an essay around them.</p>
<h2 id="this-post-has-one">This post has one</h2>
<p>Everything on this site, twenty-one chapters, in a single file — set in the two typefaces the engine bundles and coloured with its seven palette keys, because a document about a blog engine may as well look like the blog.</p>
<a class="file-card" href="https://blogsh.app/posts/2026/some-posts-are-downloads/01.pdf" download><span class="file-icon">PDF</span><span class="file-meta"><span class="file-label">./blog.sh — what it does, and why it doesn&#39;t do the rest</span><span class="file-sub">PDF · 1.3 MB</span></span><svg class="file-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13"/><path d="M6 12l6 6 6-6"/><path d="M5 21h14"/></svg></a>
<p>That card is the feature. Label, extension, size — and a file that lives here, next to the post, rather than at an address that might stop answering one day.</p>]]></description>
  <category>content</category><category>markdown</category><category>authoring</category>
</item>
<item>
  <title>The post that stays</title>
  <link>https://blogsh.app/posts/2026/the-post-that-stays/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-post-that-stays/</guid>
  <pubDate>Wed, 05 Aug 2026 22:19:55 +0200</pubDate>
  <description><![CDATA[<p>A blog&#39;s front page is the only page that&#39;s a statement. Everything else is chronology doing its job.</p>
<p>So <code>pinned: true</code> in a published post&#39;s header now holds a copy of it at the top of the first listing page. That&#39;s the whole feature, and the interesting part is everywhere it deliberately doesn&#39;t apply.</p>
<h2 id="only-the-front-page">Only the front page</h2>
<p>Type listings, tag listings, the RSS feed and the sitemap stay strictly chronological. A pin is a claim about what a visitor should read first, not a rewrite of when things were written. Someone subscribed to the feed gets posts in the order they happened, and a pinned post doesn&#39;t jump their queue every time it&#39;s toggled.</p>
<h2 id="what-happens-as-it-ages">What happens as it ages</h2>
<p>While the post is still recent enough to sit on page 1 by date, it appears exactly once — pinning it doesn&#39;t print it twice on the same screen. Once it has aged onto page 2, it appears in both places: at the top of page 1, and in its own chronological spot on page 2.</p>
<p>That second half is the part I&#39;d have got wrong if I hadn&#39;t thought about the archive. A pinned post that vanished from its own date would leave a hole in the record, and the record is the point.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>One or two files in a deploy. Pagination here is anchored — page 2 doesn&#39;t renumber itself because page 1 changed — so toggling a pin doesn&#39;t reshuffle the archive behind it.</p>
<p>Visually it costs even less: the pinned copy carries a small mark in the corner of its date badge, drawn in the neutral colour pair the badge already inverts to on hover. No new colour entered the palette. Seven keys are still seven keys.</p>
<h2 id="turning-it-on">Turning it on</h2>
<p>Put <code>pinned: true</code> in the post&#39;s header, or press <code>c</code> in the properties dialog. Every list and picker marks it <code>[PINNED]</code> afterwards, so you can&#39;t forget which post is currently doing the talking.</p>]]></description>
  <category>content</category><category>authoring</category>
</item>
<item>
  <title>Some posts are conversations</title>
  <link>https://blogsh.app/posts/2026/some-posts-are-conversations/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/some-posts-are-conversations/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:33 +0000</pubDate>
  <description><![CDATA[<dl class="chat"><dt>Petr</dt><dd>I need a dialogue in a blog post.</dd><dt>./blog.sh</dt><dd>Write it in a chat fence. Name, colon, line.</dd><dt>Petr</dt><dd>That&#39;s it?</dd><dt>./blog.sh</dt><dd>A line without a colon continues the one above it.</dd><dt>Petr</dt><dd>And Markdown inside?</dd><dt>./blog.sh</dt><dd>Works. Bold, links, code — the usual.</dd><dt>Petr</dt><dd>Every other engine wants a plugin for this.</dd><dt>./blog.sh</dt><dd>Every other engine wants a plugin for tables, too.</dd></dl>
<p>The source of that exchange is exactly what you just read — speaker&#39;s name, a colon, the line:</p>
<pre class="code-block"><code>Petr: I need a dialogue in a blog post.
./blog.sh: Write it in a chat fence. Name, colon, line.
Petr: That&#39;s it?</code></pre>
<p>Wrap those lines in a code fence tagged <code>chat</code> and you&#39;re done.</p>
<p>You just read the feature. That&#39;s the whole documentation.</p>]]></description>
  <category>markdown</category><category>authoring</category>
</item>
<item>
  <title>$ ./blog.sh add</title>
  <link>https://blogsh.app/posts/2026/blog-sh-add/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/blog-sh-add/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:27 +0000</pubDate>
  <description><![CDATA[<figure><video controls preload="metadata" width="360" height="640" src="https://blogsh.app/posts/2026/blog-sh-add/01.mp4"></video><figcaption>From an empty terminal to a shareable draft, uncut.</figcaption></figure>
<p>One take, fifty-seven seconds, no commentary and no cuts. A phone with a folding keyboard, and <code>./blog.sh</code> running over SSH on the screen behind it.</p>
<p>What you&#39;re watching:</p>
<ol><li><code>./blog.sh</code> with no arguments — a menu, arrow keys, one-key answers.</li><li><code>add</code> opens the editor. The post is Markdown with a tiny frontmatter on top.</li><li>Saving builds the draft and deploys it to a hidden preview URL — which is where the video ends: the same draft open in a browser on the desktop, and then on a tablet.</li></ol>
<p>That ending is the point. The draft is already a real page on the real site, behind an unguessable URL — reviewable from any device that can open a link, shareable with anyone you want feedback from. The one step the video doesn&#39;t show is a single menu choice — publish — after which the announcement toot goes out on its own.</p>
<p>No admin panel logged into, no step you couldn&#39;t do over SSH from a train.</p>
<p>Two footnotes for the sceptics. The real-time pace is the argument — that&#39;s why there are no cuts. And the whole wizard degrades gracefully: run it in a pipe and it falls back to plain line prompts with no escape sequences, so everything you just watched can also be scripted.</p>]]></description>
  <category>authoring</category>
</item>

  </channel>
</rss>
