<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>./blog.sh manual</title>
    <link>https://manual.blogsh.app/</link>
    <atom:link href="https://manual.blogsh.app/rss.xml" rel="self" type="application/rss+xml" />
    <description>The manual of ./blog.sh, a minimalist blog engine you run from a terminal.</description>
    <language>en</language>
    <lastBuildDate>Mon, 12 Oct 2026 12:00:00 +0200</lastBuildDate>
    <item>
  <title>Start here</title>
  <link>https://manual.blogsh.app/posts/2026/start-here/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/start-here/</guid>
  <pubDate>Mon, 12 Oct 2026 12:00:00 +0200</pubDate>
  <description><![CDATA[<p>The <a href="https://github.com/DanielSnor/blog.sh/blob/main/README.md">main README</a> is the tour: what the engine is, what it can do, and why it&#39;s built the way it is. This manual holds the guides that go deeper:</p>
<div class="table-wrap"><table><thead><tr><th>Chapter</th><th>Answers</th></tr></thead><tbody><tr><td><a href="/series/getting-started/">Getting started</a></td><td>How do I get from zero to a deployed site -- locally, on a VPS, on Cloudron, on Pages?</td></tr><tr><td><a href="/series/writing/">Writing</a>, <a href="/series/the-archive/">The archive</a>, <a href="/series/running-it/">Running it</a></td><td>How do I run it day to day -- writing, deploying, cron, backup, troubleshooting?</td></tr><tr><td><a href="/series/architecture/">Architecture</a></td><td>How does it work inside -- content model, build pipeline, deploy, client side, security?</td></tr><tr><td><a href="/series/decisions/">Decisions</a></td><td>Why is it built this way -- the trade-offs, each with its admitted cost?</td></tr><tr><td><a href="/series/importing/">Importing</a></td><td>How do I bring an old blog in -- per-source guides, what gets kept, how to undo?</td></tr><tr><td><a href="/series/skinning/">Skinning</a></td><td>How do I make it look like something else -- without forking the engine?</td></tr><tr><td><a href="/series/localization/">Localization</a></td><td>How do I translate the engine into my language?</td></tr><tr><td><a href="/series/releasing/">Releasing</a></td><td>What has to be true before a tag, and what happens after it?</td></tr></tbody></table></div>
<h2 id="where-to-start-by-situation">Where to start, by situation</h2>
<div class="table-wrap"><table><thead><tr><th>Situation</th><th>Read</th></tr></thead><tbody><tr><td>Evaluating the engine</td><td>main README → the screenshots → <i>Why this exists</i> → <i>What it does</i></td></tr><tr><td>Setting up a site</td><td><a href="/series/getting-started/">Getting started</a>, top to bottom</td></tr><tr><td>Writing and publishing posts</td><td><a href="/posts/2026/writing-and-publishing/">Writing → Writing and publishing</a></td></tr><tr><td>Dressing the site differently</td><td><a href="/series/skinning/">Skinning</a> -- try <code>./style.sh</code> first; a skin is the expensive answer</td></tr><tr><td>Moving content in from elsewhere</td><td><a href="/series/importing/">Importing</a>, then <code>./import.sh</code></td></tr><tr><td>Taking content out, or moving the whole site</td><td><a href="/posts/2026/taking-your-content-elsewhere/">The archive → Taking your content elsewhere</a>, then <code>./blog.sh export</code></td></tr><tr><td>Checking that the archive itself is sound</td><td><a href="/posts/2026/checking-the-archive/">The archive → Checking the archive</a>, then <code>./blog.sh check</code></td></tr><tr><td>Something failed</td><td><a href="/posts/2026/troubleshooting/">Running it → Troubleshooting</a></td></tr><tr><td>Understanding the internals</td><td><a href="/series/architecture/">Architecture</a>, then the source -- every <code>lib/</code> file opens with a design comment</td></tr><tr><td>Translating the engine</td><td><a href="/series/localization/">Localization</a> -- a partial locale is useful from day one</td></tr><tr><td>Writing an importer</td><td><a href="/posts/2026/importing/">Architecture → Importing</a> for the adapter contract, <a href="/posts/2026/content-model/#field-reference">→ Field reference</a> for the schema to produce</td></tr><tr><td>Questioning a design choice</td><td><a href="/series/decisions/">Decisions</a> first -- it may already argue both sides</td></tr></tbody></table></div>
<p>The <code>screenshot-*.png</code> files here are the images embedded in the main README.</p>]]></description>
  <category>start</category>
</item>
<item>
  <title>What you need</title>
  <link>https://manual.blogsh.app/posts/2026/what-you-need/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/what-you-need/</guid>
  <pubDate>Mon, 12 Oct 2026 11:13:00 +0200</pubDate>
  <description><![CDATA[<p>From zero to a deployed site. The <a href="https://github.com/DanielSnor/blog.sh/blob/main/README.md">main README</a> is the quick tour; this is the complete path, including the server side. Day-to-day usage lives in <a href="/series/writing/">Writing</a>.</p>
<p>In a hurry? The <a href="/posts/2026/quick-start/">Quick start</a> below is a complete copy-paste path to a site running locally on your machine -- one block per platform. The numbered sections after it are the full reference, including <a href="/posts/2026/pick-a-deploy-target/">picking a deploy target</a> to put the site on the internet.</p>
<ul><li><b>Ruby 2.7+</b> (3.x is what the real deployments run) -- standard library only, no gems, no Bundler. Check with <code>ruby -v</code>; <code>blog.sh</code> checks too and says exactly this if the interpreter is too old.</li><li><b>bash</b> -- for the thin <code>blog.sh</code> / <code>deploy-web.sh</code> / <code>refresh-sidebar.sh</code> wrappers. (On Windows that means WSL2 -- see the quick start; there is no native cmd/PowerShell path.)</li><li><b>A place to serve static files</b> -- any of the six deploy targets below, from a Cloudron Surfer app to a plain directory behind your own nginx/Caddy.</li><li>Optional: a <b>Mastodon or Bluesky account</b> (comments, auto-announce, sidebar widgets) and <b>cron</b> (widget refresh, scheduled publishing).</li><li>Optional, only with <code>media.convert_heic: true</code> (converting iPhone HEIC photos to JPEG on save): an image tool the machine typically already has -- <code>sips</code> is part of macOS; on Linux any of <code>heif-convert</code> (libheif-examples), ImageMagick with the HEIF delegate, or vips. Without one, the engine refuses the file with instructions instead of breaking; off by default.</li><li>Optional, only with <code>media.remux_video: true</code> (moving a video&#39;s index to the front of the file, and out of the QuickTime container, on save): <b>ffmpeg</b>. Without it the post is still saved and the engine names the command instead; off by default.</li></ul>
<h2 id="all-of-that-as-one-table">All of that as one table</h2>
<p>The same thing in a form you can scan before installing anything. The right-hand column is what happens if it is missing, because that is the question worth answering: almost nothing here stops the engine, it just switches something off and says so.</p>
<div class="table-wrap"><table><thead><tr><th>What</th><th>Version</th><th>Needed for</th><th>Without it</th></tr></thead><tbody><tr><td>Ruby</td><td>2.7+, 3.x on the real deployments</td><td>everything</td><td><code>blog.sh</code> refuses to start and says the version it found</td></tr><tr><td>bash</td><td>any</td><td>the <code>blog.sh</code>, <code>deploy-web.sh</code> and <code>refresh-sidebar.sh</code> wrappers</td><td>no CLI; on Windows this is what WSL2 is for</td></tr><tr><td>macOS</td><td>any current release</td><td>—</td><td>—</td></tr><tr><td>Linux</td><td>any distro</td><td>—</td><td>—</td></tr><tr><td>Windows</td><td>via WSL2</td><td>—</td><td>there is no native cmd/PowerShell path</td></tr><tr><td>cron</td><td>any</td><td>scheduled publishing, sidebar refresh, comment moderation</td><td>those three; everything else is unaffected</td></tr><tr><td>ffmpeg</td><td>any</td><td><code>media.remux_video: true</code></td><td>the post is saved anyway and the engine prints the command to run by hand</td></tr><tr><td><code>sips</code>, <code>heif-convert</code>, ImageMagick with HEIF, or vips</td><td>any</td><td><code>media.convert_heic: true</code></td><td>the HEIC is refused with instructions rather than half-converted</td></tr><tr><td>rsync</td><td>any</td><td>the <code>rsync</code> deploy backend</td><td>that backend only</td></tr><tr><td>ssh + sftp</td><td>any</td><td>the <code>sftp</code> backend</td><td>that backend only</td></tr><tr><td>git</td><td>any</td><td>the <code>git</code> backend (Pages)</td><td>that backend only</td></tr><tr><td>rclone</td><td>any</td><td>the <code>rclone</code> backend (S3, R2, B2, WebDAV, FTP)</td><td>that backend only</td></tr><tr><td>nothing extra</td><td>—</td><td>the <code>surfer</code> and <code>local</code> backends</td><td>—</td></tr></tbody></table></div>
<p>No gems, no Bundler, no lockfile: the engine is the standard library and these binaries, which is why an upgrade is a <code>git pull</code> and nothing else.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>Quick start</title>
  <link>https://manual.blogsh.app/posts/2026/quick-start/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/quick-start/</guid>
  <pubDate>Mon, 12 Oct 2026 11:12:00 +0200</pubDate>
  <description><![CDATA[<p>Each block below is the whole path for one platform: prerequisites, clone, config, first post, local preview. They end at the same place -- a site you can see at <code><a href="http://localhost:8000/">http://localhost:8000/</a></code> -- and from there, <a href="/posts/2026/pick-a-deploy-target/">section 6</a> takes it to the internet.</p>
<p><code>./setup.sh</code> is the config step: it asks for the settings a site needs, checks the answers as it goes, and writes <code>config/site.yml</code> and <code>env.sh</code> for you. Most questions can be skipped with Enter, which keeps what is there -- the ones that cannot are the site&#39;s title, short name, description, author and address for as long as they still hold the template&#39;s examples -- and nothing is written until you have seen the diff and confirmed it -- so it is also the way to change any of this later. If you would rather edit the files yourself, the numbered sections below are the full reference and <a href="/posts/2026/configure-the-site-config-site-yml/">section 2</a> still starts with the two <code>cp</code> commands; the wizard leaves both files commented and hand-editable either way.</p>
<h2 id="macos">macOS</h2>
<p>The system Ruby is 2.6 from 2019 and Apple treats it as frozen, so the one real step is a current Ruby via <a href="https://brew.sh">Homebrew</a>:</p>
<pre class="code-block"><code class="language-bash">brew install ruby
echo &#39;export PATH=&quot;/opt/homebrew/opt/ruby/bin:$PATH&quot;&#39; &gt;&gt; ~/.zshrc
exec zsh
ruby -v    # 3.x now, not 2.6.10</code></pre>
<p>(That PATH line is for Apple Silicon; on an Intel Mac it&#39;s <code>/usr/local/opt/ruby/bin</code>. <code>brew install ruby</code> prints the exact line for your machine at the end of its output -- trust that one.)</p>
<p>(No Homebrew yet? Install it first with the one command on <a href="https://brew.sh">brew.sh</a>. <code>git</code> is already there on any Mac with the Xcode Command Line Tools -- macOS offers to install them the first time you type <code>git</code>.)</p>
<p>Then:</p>
<pre class="code-block"><code class="language-bash">git clone https://github.com/DanielSnor/blog.sh.git myblog
cd myblog
./setup.sh           # a few questions -&gt; config/site.yml and env.sh
./blog.sh add        # write the first post; publish or keep as draft
./blog.sh preview    # -&gt; http://localhost:8000/  (Ctrl-C stops it)</code></pre>
<p>The system nano is as old as the system Ruby -- if the editor step complains about options, set your own: <code>export EDITOR=vim</code> (or <code>code -w</code>, or plain <code>nano</code>).</p>
<p><b>An icon instead of </b><b><code>cd myblog &amp;&amp; ./blog.sh</code></b><b>.</b> On a Mac <code>./setup.sh</code> ends by offering to put a launcher in Applications: an icon, named after the site and wearing its favicon, that opens Terminal in the site&#39;s folder and runs <code>./blog.sh</code>. It is nothing more than that -- no window of its own, nothing blog.sh does not do -- and the answer to Enter is no, since a yes puts something outside this folder. <code>./setup.sh --launcher</code> makes it at any later time, and makes it again for a site whose folder moved: the folder is written into the launcher, so one site has one launcher -- under one name. The launcher is called what the site is called: after a change of <code>site.short_name</code> the next <code>--launcher</code> makes one under the new name, and the old one stays where it was until it is dragged to the Trash.</p>
<p>Three things to know about it, all of them macOS&#39;s:</p>
<ul><li>The first double-click asks whether the launcher may control Terminal, and under the question stands one sentence, in the site&#39;s language, saying what the launcher does. Allow it. Refused, the icon does nothing, and the question is not asked again -- the switch is then in System Settings → Privacy &amp; Security → Automation.</li><li>blog.sh runs in the window Terminal opens as it starts, or in a new one when Terminal was already running. Either way Terminal keeps the window open after blog.sh ends, saying the process completed. Whether it closes instead is Terminal&#39;s own setting: Settings → Profiles → Shell → &quot;When the shell exits&quot;.</li><li>It lands in <code>/Applications</code>, or in <code>~/Applications</code> for an account that may not write there. An application of the same name that blog.sh did not make is never replaced, and neither is the launcher of ANOTHER site called the same -- two sites nobody has named yet are both &quot;blog.sh&quot;; give one a <code>site.short_name</code> of its own. <code>BLOG_SH_LAUNCHER_DIR=&lt;folder&gt;</code> puts the launcher somewhere else.</li></ul>
<h2 id="linux-debian-ubuntu-shown-any-distro-works">Linux (Debian/Ubuntu shown; any distro works)</h2>
<pre class="code-block"><code class="language-bash">sudo apt update &amp;&amp; sudo apt install -y ruby-full git</code></pre>
<p>(<code>ruby-full</code> rather than the bare <code>ruby</code> -- see the default-gems note below. On Fedora: <code>sudo dnf install ruby</code>; on Arch: <code>sudo pacman -S ruby</code> -- both already complete.)</p>
<pre class="code-block"><code class="language-bash">git clone https://github.com/DanielSnor/blog.sh.git myblog
cd myblog
./setup.sh           # a few questions -&gt; config/site.yml and env.sh
./blog.sh add        # write the first post; publish or keep as draft
./blog.sh preview    # -&gt; http://localhost:8000/  (Ctrl-C stops it)</code></pre>
<h2 id="windows">Windows</h2>
<p>blog.sh&#39;s wrappers are bash, so on Windows it runs inside <b>WSL2</b> -- Microsoft&#39;s Linux environment, one command to set up. In PowerShell <b>as Administrator</b>:</p>
<pre class="code-block"><code class="language-powershell">wsl --install</code></pre>
<p>Reboot when asked; Ubuntu opens and asks you to pick a username. From that Ubuntu terminal, it&#39;s the Linux path verbatim:</p>
<pre class="code-block"><code class="language-bash">sudo apt update &amp;&amp; sudo apt install -y ruby-full git
git clone https://github.com/DanielSnor/blog.sh.git myblog
cd myblog
./setup.sh           # a few questions -&gt; config/site.yml and env.sh
./blog.sh add        # write the first post; publish or keep as draft
./blog.sh preview    # -&gt; http://localhost:8000/  (Ctrl-C stops it)</code></pre>
<p>Two Windows-specific notes: clone into the Linux home (<code>~/myblog</code>, as above), not <code>/mnt/c/...</code> -- file permissions (<code>chmod 600</code> on your tokens) and speed only work properly on the Linux side; and <code><a href="http://localhost:8000/">http://localhost:8000/</a></code> works straight from your Windows browser, WSL2 forwards it. Git Bash instead of WSL2 mostly runs too, but the interactive menus degrade (mintty isn&#39;t a TTY to a native Ruby), non-ASCII output is garbled until the console is switched to UTF-8 (<code>chcp 65001</code>), and <code>chmod</code> protects nothing on NTFS -- with real tokens in env.sh, WSL2 is the supported route.</p>
<p>The engine has no build-time network dependencies: a machine with Ruby and bash can build the whole site offline.</p>
<p><b>On &quot;no gems&quot; and default gems.</b> Everything in blog.sh is written against Ruby&#39;s core standard library -- nothing to <code>gem install</code>, ever, for the engine itself to run. The one nuance: the optional Pixelfed/RSS sidebar widgets parse XML with <code>rexml</code>, which Ruby ships as a <i>default gem</i> -- bundled with a normal <code>ruby</code> install, but some Linux distributions split their Ruby package and leave default gems out of the minimal one. Importing is not optional about it: every XML source reads through <code>rexml</code>, which is WordPress, Blogger, Squarespace, podcasts, any RSS or Atom feed, the Wayback rescue and LiveJournal. If none of that is in your way -- no XML import, no <code>widgets.pixelfed</code>/<code>widgets.rss</code> -- it never comes up; if it is, and you see a <code>LoadError</code> about <code>rexml</code>, either <code>gem install rexml</code> or install your distro&#39;s fuller Ruby package -- e.g. <code>ruby-full</code> instead of the bare <code>ruby</code> on Debian/Ubuntu (Arch&#39;s own <code>ruby</code> package already includes the full standard library, no separate install needed there). <code>./blog.sh preview</code>, by contrast, needed no such caveat to begin with: it&#39;s a small built-in static server (<code>lib/preview_server.rb</code>), not the <code>webrick</code>-dependent <code>ruby -run -e httpd</code> one-liner some other guides suggest.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>Configure the site -- config/site.yml</title>
  <link>https://manual.blogsh.app/posts/2026/configure-the-site-config-site-yml/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/configure-the-site-config-site-yml/</guid>
  <pubDate>Mon, 12 Oct 2026 11:11:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">git clone https://github.com/DanielSnor/blog.sh.git myblog
cd myblog</code></pre>
<p>One clone = one site. Everything personal (posts, media, config, secrets) is gitignored, so pulling engine updates later never touches your content -- see <a href="/posts/2026/updating-the-engine/">Updating the engine</a>.</p>
<pre class="code-block"><code class="language-bash">cp config/site.yml.example config/site.yml</code></pre>
<p>Or let <code>./setup.sh</code> do it: it seeds the file from this same example -- comments and all -- and fills in the answers you give it, which is the same file you would have written by hand, minus the chance of a tab where a space belongs. It covers the <code>site</code> block, the comments network and the deploy target; <code>./style.sh</code> covers <code>banner</code>, <code>about</code>, <code>footer</code>, <code>social</code>, <code>colors</code>, <code>fonts</code>, <code>widgets</code> and <code>nav</code>. Both are re-runnable and neither takes anything away from editing the file directly.</p>
<p>If you do edit it directly -- which is a first-class way to use this engine, not a fallback -- run <code><b>./blog.sh doctor</b></code> when you are done. It reads the file you just wrote and reports every problem in it at once, which is the half a hand edit does not get for free: an unclosed quote, a tab, a value in the wrong section, a token in <code>env.sh</code> with no section to use it. It is also the answer when a YAML error names a line that looks perfectly fine -- the parser stops where things stop fitting, which can be a long way from the mistake.</p>
<p>The example is fully commented. The short version:</p>
<ul><li><b>Required:</b> <code>site</code> (title, short_name, description, author, lang, locale, base_url) and <code>banner</code>. That alone is a complete, working site.</li><li><b><code>about</code></b><b> and </b><b><code>footer</code></b> introduce the site and close every page; the example ships both filled in. Neither is required: an emptied value takes its section with it -- heading and all -- rather than rendering empty, and a deleted block reads the same as an emptied one.</li><li><b>Optional, each activates only when present:</b> <code>analytics</code>, <code>social</code>, <code>widgets</code> (toots / pixelfed / commits / bluesky / rss, each independently), <code>mastodon</code> <b>or</b> <code>bluesky</code> (comments + auto-announce -- exactly one, see <a href="/posts/2026/comments-network-optional-mastodon-or-bluesky/">step 8</a>), <code>comments</code> (which replies get published -- same step), <code>colors</code> (7 keys per light/dark mode -- omitted keys fall back to the built-in blue palette; whole palettes ship in <code>config/palettes.yml</code>, and <code>./style.sh</code> shows you a preview -- light and dark side by side -- and writes the one you pick into this section, so you never have to choose fourteen hex values by hand or blind), <code>fonts</code> (the banner title&#39;s and claim&#39;s font stack and size, plus any <code>.woff2</code> you put in <code>assets/fonts/</code> -- omitted keys fall back to the built-in JetBrains Mono at 45px/20px), <code>nav</code> and <code>layout</code> (what the pages are made of -- <a href="#the-menu-the-regions-and-a-stylesheet-of-your-own">below</a>), <code>publishing.slots</code> (the times posts usually go out, so scheduling stops asking for a date -- <a href="/posts/2026/publishing-slots/">Writing</a>), <code>publishing.allow_partial</code> (on a site of several languages, lets a post out without the words of every one of them, unasked -- <a href="/posts/2026/publishing-in-more-than-one-language/#publishing-a-post-that-is-not-in-every-language">Localization</a>), <code>media</code> (<code>convert_heic</code>, <code>remux_video</code> and <code>strip_location</code>, all discussed under <a href="/posts/2026/writing-from-a-phone/">Writing from a phone</a>), <code>tag_icons</code> (an icon a tag carries, on its own listing and on the date badge of every post that has it -- <a href="/posts/2026/giving-a-tag-its-own-icon/">The archive</a>), <code>share</code> (the row of controls under a post, drawn in the order you name them -- <a href="/posts/2026/letting-a-reader-pass-a-post-on/">The archive</a>), <code>write</code> (<code>write: true</code> publishes a page at <code>/write/</code> to write a post on from a phone -- <a href="/posts/2026/writing-from-a-phone/#a-post-sent-from-the-phone-itself">Writing</a>), and <code>seo</code> (<code>block_ai_crawlers</code> writes a maintained list of the crawlers that collect text to train on into <code>robots.txt</code>, <code>robots_extra</code> is appended verbatim -- off by default, because wanting to be findable in an answer somebody gets from a machine is a legitimate position and the engine does not take it on your behalf either way).</li></ul>
<p>The <code>commits</code> widget reads GitHub unless you give it <code>instance</code> -- the address of a Gitea or Forgejo server (Codeberg, your own git). The address is the whole configuration: nothing else has to be said, because it already answers which kind of host this is. That path costs one request where GitHub costs one per commit, since a Gitea activity item carries the commits it is about.</p>
<p><code>social</code>, <code>footer.links</code> and <code>nav</code> are lists, and a list key holds a list or nothing at all: emptiness is a legitimate answer everywhere. Anything else under one of them -- a single URL pasted where a list belongs -- is a mistake <code>doctor</code> names and the build reads as empty, rather than a traceback out of an engine file.</p>
<p><code>social</code> is the row of icons in the footer. Each entry takes <code>name</code>, <code>url</code> and either <code>icon</code> (a name from the built-in set: the network marks mastodon, pixelfed, linkedin, github, gitea, forgejo, codeberg, gitlab, bluesky, instagram, threads, facebook, x, youtube, rss, email -- or any of the general drawings the engine ships for tag icons, <code>globe</code> for somebody&#39;s other site among them, <a href="/posts/2026/giving-a-tag-its-own-icon/">listed in operations.md</a>) or <code>icon_svg</code> (your own markup), plus an optional <code>rel</code> that is passed through to the rendered link. <code>rel: &quot;me&quot;</code> on the Mastodon entry is what gets your site verified -- the green check mark next to it on your profile: Mastodon fetches the address from your profile&#39;s metadata field and accepts it only if that page links back to the profile with <code>rel=&quot;me&quot;</code>. The footer is on every page, so pointing the profile field at your home page is enough; the entry&#39;s <code>url</code> has to be the profile as Mastodon shows it (<code><a href="https://instance/@handle">https://instance/@handle</a></code>). Several entries may carry it. Bluesky verifies domains a different way (DNS or <code>/.well-known</code>), so <code>rel: &quot;me&quot;</code> does nothing for a Bluesky entry.</p>
<p><code>site.lang</code> selects <code>locales/&lt;lang&gt;.yml</code> for every generated string -- <code>en</code>, <code>cs</code> and <code>de</code> ship with the engine; a partial locale falls back to English per key. Adding another language is data, not code -- see <a href="/series/localization/">Localization</a>.</p>
<p><code>site.locales</code> is the list of languages the site <b>publishes</b>, and it is only needed when that is more than one. The language in <code>site.lang</code> keeps the site root; every other one is built into a root of its own (<code>/de/</code>), with one copy of <code>/assets/</code> and one writing app shared between them. The words of a post in another language are written with <code>./blog.sh translate</code> (see <a href="/posts/2026/writing-a-post-in-another-language/">Writing</a>), and <code>./blog.sh rebuild</code> produces every language in one go. Leave the key out and nothing about the site changes -- no switcher, no alternates, nothing in the markup.</p>
<p><code>site.timezone</code> (an IANA name like <code>Europe/Prague</code>) is the zone every timestamp the engine writes is expressed in. <b>Set it if you&#39;ll ever publish from a server</b>, because a server&#39;s clock is usually UTC: without it, <code>schedule</code> reads &quot;10:30&quot; as 10:30 UTC, and a post written after midnight local time can be dated to the previous day. Omit it to use the machine&#39;s own zone. A name the system doesn&#39;t know is refused at startup rather than silently treated as UTC. It also governs the dates readers see, including the sidebar widgets, whose sources report UTC. Existing posts keep the stored offset -- setting this later rewrites no history, it only changes the day shown for posts whose local day genuinely differs (and never a post&#39;s URL).</p>
<h2 id="the-palette-and-the-header-s-type">The palette and the header&#39;s type</h2>
<p><code>colors.light</code> and <code>colors.dark</code> take the same seven keys -- <code>bg</code>, <code>text</code>, <code>meta_text</code>, <code>accent</code>, <code>nav_bg</code>, <code>border</code>, <code>pill_bg</code> -- and the build compiles them into <code>assets/css/colors.css</code>. Everything else the stylesheet needs is derived from those seven rather than configured separately: the card background, the nav&#39;s text and border, the hover states of links and badges, the search field&#39;s background. That is why there are seven keys and not twenty, and why a palette cannot end up internally inconsistent. Omit the section entirely and the site uses blog.sh&#39;s own blue (<code>DEFAULT_COLORS</code>).</p>
<p><code>assets/css/site.css</code> is the other half, and it is not generated: layout and structure, plus the few colors that are fixed because they work against any palette -- white on accent surfaces, the dark scrims behind the banner overlays and the lightbox, the search field&#39;s grey placeholder. Changing <code>colors:</code> is configuration; editing that file is editing an engine file, with the pull conflicts that implies (see <a href="/posts/2026/updating-the-engine/">step 9</a>).</p>
<p><code>fonts.banner_title</code> and <code>fonts.banner_claim</code> take a CSS font stack each, <code>fonts.banner_title_size</code> and <code>fonts.banner_claim_size</code> any CSS length, and they compile into that same generated stylesheet. Drop a <code>.woff2</code> into <code>assets/fonts/</code>, declare it under <code>fonts.faces</code>, and the header is in your own type; say nothing and it stays self-hosted JetBrains Mono at 45px/20px. Narrow screens scale from whatever size is configured, so there is no second pair of keys to keep in sync.</p>
<h2 id="the-menu-the-regions-and-a-stylesheet-of-your-own">The menu, the regions and a stylesheet of your own</h2>
<p>Four more settings decide what the pages are <i>made of</i> rather than what they look like. Each has a default that leaves a site saying nothing exactly as it was, so none of them has to be touched.</p>
<p><code>nav:</code> replaces the menu bar the build otherwise derives from the content types that actually have posts. An entry takes a <code>label</code> plus either a <code>tag</code> (a tag&#39;s slug, which becomes <code>/tag/&lt;slug&gt;/</code>) or a <code>url</code> taken as written -- so an item can point at a post, at a generated page, or off the site entirely:</p>
<pre class="code-block"><code class="language-yaml">nav:
  - { label: &quot;Home&quot;, url: &quot;/&quot; }
  - { label: &quot;Photographs&quot;, tag: &quot;photo&quot; }</code></pre>
<p>An entry missing either half is skipped rather than rendered as an empty link. An empty list is a decision rather than a mistake: the entries and the button that opens them are gone, which is also how a site turns the menu off, so there is no second key for that. The bar itself stays, since the search field is rendered inside it. <code>nav:</code> with nothing under it at all -- the key left standing after its entries were deleted -- reads the same way, because a key that is written down is an answer; leave the key out entirely to ask the engine for its own menu again.</p>
<p><code>layout.sidebar</code> and <code>layout.hero</code> switch whole regions on and off. The sidebar is on unless you say otherwise -- though it is only drawn when something would stand in it, so a site with no about text and no widgets gets its full width back without having to say so; the hero -- the post&#39;s first usable image lifted out of the text and run full width above the title -- is off unless you ask, because it reshapes every post page it touches and a site is entitled to keep the shape it has. A single post can still ask for one either way with <code>hero:</code> in its own header (see <a href="/posts/2026/writing-and-publishing/">Writing</a>). They are switches for regions, not for how a region looks: what things look like belongs in a stylesheet, and a key per visual property would turn this file into a stylesheet written in YAML.</p>
<p><code>site.extra_css</code> is that stylesheet -- one path, or a list of them, loaded after the site&#39;s own, so a skin can live in a file of yours instead of in an edited engine file with the pull conflicts that implies (<a href="/posts/2026/updating-the-engine/">step 9</a>). Local paths only (<code>/assets/css/mine.css</code>): every page carries <code>style-src &#39;self&#39;</code>, so a stylesheet on another host would be discarded by the browser without an error you would ever see -- the page would simply render undressed -- and the build refuses it out loud instead.</p>
<p><code>site.page_size</code> is how many posts a listing page holds; 10 without it. Worth setting once, before the first deploy: pagination is anchored to the oldest post precisely so page contents never change as new posts arrive, and changing the size later renumbers every page in the archive.</p>
<h2 id="four-starting-points">Four starting points</h2>
<p>Every key above is optional except the handful in <code>site:</code> and <code>banner:</code>, which is easy to say and hard to act on when you are looking at six hundred commented lines for the first time. So: four kinds of site, and the keys each one actually turns on. Everything not named stays at its default, and a default is a working site.</p>
<p>Written here rather than shipped as four <code>site.yml</code> variants on purpose. A copy of the config is a copy that goes stale the first time a key is added, and then somebody starts from a file that quietly disagrees with the engine. The reference is <code>config/site.yml.example</code>; this is a reading order through it.</p>
<p><b>A plain blog.</b> <code>site:</code> (title, author, <code>base_url</code>, <code>lang</code>, <code>timezone</code>) and <code>banner:</code>. That is the whole minimum, and the file says so at the top. Add <code>about:</code>, <code>footer:</code> and <code>social:</code> when you want the chrome filled in.</p>
<p><b>A photo blog.</b> As above, plus <code>media.convert_heic: true</code> if the pictures come off an iPhone, and <code>media.remux_video: true</code> if there are videos and <code>ffmpeg</code> is on the machine. Leave <code>media.strip_location</code> alone: it is on by default and that is the setting that keeps your address out of the pictures. Consider a smaller <code>site.page_size</code> -- a listing card is measured in height, not characters, so a page of photo posts is several times taller than a page of text ones.</p>
<p><b>A newsletter.</b> <code>publishing.slots:</code> so posts go out on a schedule rather than when you happen to be at the keyboard, and cron to run it (see <a href="/series/writing/">operations</a>). The announce settings live in <code>env.sh</code>, not here. <code>comments.approval: fav</code> if replies should appear only once you have favourited them.</p>
<p><b>A Fediverse-facing site.</b> <code>comments:</code> pointed at your instance, <code>widgets:</code> for the sidebar, and the Mastodon or Bluesky credentials in <code>env.sh</code>. <code>seo.block_ai_crawlers: true</code> if you would rather the text was not scraped for training; it writes the robots rules and nothing else.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>Configure the environment -- env.sh</title>
  <link>https://manual.blogsh.app/posts/2026/configure-the-environment-env-sh/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/configure-the-environment-env-sh/</guid>
  <pubDate>Mon, 12 Oct 2026 11:10:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">cp env.sh.example env.sh
chmod 600 env.sh</code></pre>
<p><code>env.sh</code> holds secrets and per-environment values; it is gitignored and mode 600 because live credentials go in it. An <b>unedited copy is enough to try everything locally</b> -- with no deploy target configured, uploads are skipped (logged, not an error).</p>
<p>The one value worth knowing about right away is <code>SITE_BASE_URL</code>: the canonical URL normally comes from <code>site.base_url</code> in <code>config/site.yml</code> (step 2), and this env.sh value <b>overrides</b> it when set -- it exists so a staging or local environment can point somewhere other than production while building from the same config. One site, one URL? Set <code>site.base_url</code> and leave this out.</p>
<p>Watch the order they start in: the example above ships <code>SITE_BASE_URL</code> <b>active and pointing at </b><b><code><a href="https://example.com">https://example.com</a></code></b>, so filling in <code>site.base_url</code> carefully and leaving this file alone gives a site that still calls itself example.com in its feed, its sitemap and every share preview. Either comment this line out or give it the same address. <code>./setup.sh</code> writes both to the same value for exactly this reason, and <code>./blog.sh doctor</code> reports the address that would actually be used rather than the one in the config.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>Banner and favicon</title>
  <link>https://manual.blogsh.app/posts/2026/banner-and-favicon/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/banner-and-favicon/</guid>
  <pubDate>Mon, 12 Oct 2026 11:09:00 +0200</pubDate>
  <description><![CDATA[<p>Both ship with the engine as <code>assets/images/defaults/</code> -- the first build copies whatever is missing to the live names <code>assets/images/header.png</code> (the path <code>banner.src</code> defaults to) and <code>assets/images/favicon.png</code>, so a fresh clone renders before you&#39;ve drawn anything. The live names are gitignored: replace them with your own artwork and neither <code>git pull</code> nor a rebuild will touch it. That also means nothing else keeps a copy -- put both files in your backup (<a href="/posts/2026/backup/">Backup</a>), or a restore brings the site back wearing the engine&#39;s default artwork. Set <code>banner.width</code>/<code>height</code> to the real dimensions of your image: those attributes are what reserves space before it loads, and a mismatch makes the page jump.</p>
<p><code>./style.sh</code> does that part for you -- give it the path to an image and it copies the file into place, reads its real dimensions and writes them, so the pair can never drift from the file. <code>./blog.sh doctor</code> reports it if they ever do.</p>
<p>The favicon is used three ways from that one file: the <code>&lt;link rel=&quot;icon&quot;&gt;</code>, an <code>apple-touch-icon</code> (iOS scales it down for a home-screen bookmark), and a generated <code>/favicon.ico</code> for clients that request the root path without reading the link. A square PNG of 180 px or more covers all three.</p>
<p>The banner gets <code>site.short_name</code> rendered top-left and <code>site.description</code> bottom-right (wrapping to several lines if it is long), each darkening the corner it sits in so it stays readable against any image -- and only that corner, so a banner with both overlays off is shown exactly as authored. A calm image still works best. Each is independently optional: <code>banner.show_title</code> and <code>banner.show_claim</code> (both default true) decide whether they render at all, and <code>colors.&lt;mode&gt;.banner_title</code> / <code>colors.&lt;mode&gt;.banner_claim</code> override their color per light and dark mode -- by default <code>nav_bg</code> in light, white in dark. <code>banner.claim</code> overrides <i>only</i> the overlay&#39;s text, in Markdown or raw HTML (a manual <code>&lt;br&gt;</code>, say); <code>site.description</code> itself stays plain text everywhere else it is used, in the meta description and the feed. Their typeface and size are <code><a href="/posts/2026/configure-the-site-config-site-yml/#the-palette-and-the-header-s-type">fonts</a></code>.</p>
<p><code>banner.show_image: false</code> leaves the picture out of the header. The short name and the description then stand on the page itself, one under the other at the left edge, and the whole header is still the link home. Their colours default to the page&#39;s own <code>text</code> and <code>meta_text</code> instead of the pair chosen for reading over a picture -- <code>banner_title</code> / <code>banner_claim</code> still override them -- and their sizes are scaled down from the configured ones (32px and 16px with the defaults), since 45px is a size for a line laid across a picture. Leave <code>src</code>, <code>alt</code>, <code>width</code> and <code>height</code> as they are: <code>src</code> is also the picture a page with none of its own is shared with, and <code>alt</code> becomes the name of the link. It is a key to set by hand; <code>./style.sh</code> does not ask about it.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>First build and local preview</title>
  <link>https://manual.blogsh.app/posts/2026/first-build-and-local-preview/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/first-build-and-local-preview/</guid>
  <pubDate>Mon, 12 Oct 2026 11:08:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">./blog.sh add                  # write your first post (opens $EDITOR)
ruby build/build_blog.rb       # build into public.nosync/ (one language; ./blog.sh rebuild builds all and deploys)
./blog.sh preview               # preview at http://localhost:8000 (Ctrl-C stops it)</code></pre>
<p><code>add</code> always creates a draft and offers publishing interactively -- see <a href="/posts/2026/writing-and-publishing/">Writing</a> for the full authoring flow.</p>
<p><b>Replacing an existing blog?</b> Bring the old content in before you deploy, so the first published version of the site is already complete. <code>./import.sh</code> walks you through it -- Bluesky, Mastodon, Pixelfed, Tumblr, a Twitter/X archive export, or WordPress and any RSS/Atom feed -- and always previews what it would write before writing anything. See <a href="/posts/2026/importing-from-another-platform/">The archive → Importing</a>, and <code>./import.sh --help</code> for the scriptable equivalents.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>Pick a deploy target</title>
  <link>https://manual.blogsh.app/posts/2026/pick-a-deploy-target/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/pick-a-deploy-target/</guid>
  <pubDate>Mon, 12 Oct 2026 11:07:00 +0200</pubDate>
  <description><![CDATA[<p>Set <code>DEPLOY_BACKEND</code> in env.sh plus the values for your choice, then:</p>
<pre class="code-block"><code class="language-bash">./scripts/deploy-web.sh --dry-run   # shows what would upload, touches nothing
./scripts/deploy-web.sh             # first real deploy (uploads everything once)</code></pre>
<p>Every later deploy uploads only new/changed files -- a SHA-256 manifest (<code>.deploy_manifest*.json</code>, one per backend) tracks what the target already has, while <code>.deploy_baseline.json</code> records the shape of the last build the safety guards accepted. Both are gitignored and both are disposable.</p>
<p><b>The first deploy is rarely the one you run by hand.</b> From here on the engine deploys as part of writing: saving a draft builds the site and deploys it, because a draft&#39;s preview is a real address on the real site (see <a href="/posts/2026/writing-and-publishing/">Writing → Writing and publishing</a>). So the target is written to the first time you save anything -- not the first time you type <code>deploy-web.sh</code>. Whatever is already at that address under a name the build also uses is replaced, and a placeholder page is exactly such a name.</p>
<p>If the target still holds something you want to keep -- a &quot;coming soon&quot; page, an old site you have not moved yet -- <b>do not point </b><b><code>env.sh</code></b><b> at it until you are ready</b>. An unedited <code>env.sh</code> deploys nowhere and everything else works, so a whole site can be written, previewed locally and imported into before any address of yours is touched; set the target when the site is ready to be seen. Reported from the outside, and it cost somebody their placeholder.</p>
<p>The other way round, <b>nothing is removed</b>. A deploy writes the site&#39;s files and deletes only files an earlier deploy of this site wrote; whatever else was in the target stays. After a previous site that matters: a host typically serves <code>index.php</code> before <code>index.html</code>, so an old WordPress front page goes on answering over the new one, and an old <code>.htaccess</code> can go on rewriting addresses the new site depends on. Clear the old site out of the target before the first deploy -- over FTP, with the client or file manager you used for it -- and keep only what the host itself put there. (The git backend is the exception: every push replaces the whole branch.) <code>./blog.sh doctor --online</code> lists what stands in the target&#39;s root that the site does not put there -- run it once the target is set, before the first deploy. What stands there on purpose (screenshots an issue links to, a file a search console asked for) goes under <code>deploy.keep</code> in <code>config/site.yml</code>, and doctor stops naming it; <code>.well-known/</code> it never names.</p>
<p>One thing to know before you write your first post with a big attachment: a single file over 100 MB is refused, at save time and again at deploy time. The limit is the same for every backend so the site stays portable between them -- the strictest supported target refuses anything larger. See <a href="/posts/2026/deploying/">Deploying</a> for the rest of the guards.</p>
<h2 id="surfer-cloudron-surfer-the-default">surfer (Cloudron Surfer -- the default)</h2>
<pre class="code-block"><code class="language-bash">export SURFER_URL=https://surfer.example.com
export SURFER_USERNAME=...     # your Cloudron username, not the e-mail
export SURFER_PASSWORD=...     # an app password: Cloudron profile -&gt; App Passwords
export SURFER_REMOTE_DIR=      # optional subdirectory; empty = app root</code></pre>
<p>No <code>DEPLOY_BACKEND</code> needed -- surfer is the default whenever <code>SURFER_URL</code> is set.</p>
<p>Surfer 7 (September 2026) signs in with the Cloudron user and an <b>app password</b> -- one you create for the Surfer app in your Cloudron profile, not the password you sign in to Cloudron with. The access token of Surfer 6 and earlier (<code>SURFER_TOKEN</code>) is still read, but Surfer 7 answers it with HTTP 401, and the deploy then stops at the first file and says so. Moving over is two lines in <code>env.sh</code>: add <code>SURFER_USERNAME</code> and <code>SURFER_PASSWORD</code> and leave <code>SURFER_TOKEN</code> where it is. They can go in before the app has updated: a Surfer older than 7 answers the password with an error (HTTP 500), and the run then goes on with the token -- every run tries the password first, so the day Surfer 7 arrives it takes over by itself. <code>./blog.sh doctor --online</code> says which of the two Surfer took.</p>
<h2 id="local-a-directory-on-the-same-machine">local (a directory on the same machine)</h2>
<pre class="code-block"><code class="language-bash">export DEPLOY_BACKEND=local
export DEPLOY_TARGET_DIR=/var/www/mysite</code></pre>
<p>For a docroot served by your own nginx/Caddy, or a mounted volume. Your web server handles HTTPS (Caddy does it automatically; certbot for nginx). The engine&#39;s CSP arrives via a meta tag so no header configuration is required -- but if you can set real HTTP headers, nothing stops you from adding more.</p>
<h2 id="rsync-any-ssh-host">rsync (any SSH host)</h2>
<pre class="code-block"><code class="language-bash">export DEPLOY_BACKEND=rsync
export RSYNC_TARGET=user@server:/var/www/mysite
export RSYNC_SSH=&quot;ssh -p 2022&quot;   # optional, only for a non-default ssh</code></pre>
<p>The most universal remote option -- works against any VPS or shared host with SSH and rsync installed.</p>
<h2 id="git-github-gitlab-codeberg-pages">git (GitHub / GitLab / Codeberg Pages)</h2>
<pre class="code-block"><code class="language-bash">export DEPLOY_BACKEND=git
export GIT_PAGES_REMOTE=git@github.com:you/yoursite.git
export GIT_PAGES_BRANCH=gh-pages       # optional, this is the default
export GIT_PAGES_CNAME=www.example.com # optional custom domain</code></pre>
<p>Free hosting with HTTPS. Setup on the host&#39;s side (GitHub shown, GitLab/Codeberg analogous): create a repository, then Settings → Pages → Build and deployment → Source → &quot;Deploy from a branch&quot; → <code>gh-pages</code>. Every deploy force-pushes the build as a single-commit snapshot; a custom domain must be set via <code>GIT_PAGES_CNAME</code> (the host stores it as a CNAME file <i>in the branch</i>, which a snapshot push would otherwise wipe).</p>
<h2 id="rclone-s3-r2-b2-webdav-ftp">rclone (S3, R2, B2, WebDAV, FTP, ...)</h2>
<pre class="code-block"><code class="language-bash">export DEPLOY_BACKEND=rclone
export RCLONE_TARGET=r2:my-bucket/site
export RCLONE_ARGS=&quot;--s3-acl public-read&quot;   # optional provider flags</code></pre>
<p>Run <code>rclone config</code> once to set up the remote -- credentials live in rclone&#39;s own config, never in env.sh. Needs the <code>rclone</code> binary installed -- from your distribution&#39;s packages or from rclone.org, not as a snap. The snap is not published by the rclone project, and it is confined to your home directory: with the site anywhere else (<code>/srv</code>, <code>/var/www</code>, <code>/opt</code>) it cannot read a single file of it, and says so as &quot;no such file or directory&quot; about a file that is plainly there. <code>./blog.sh doctor</code> notices the snap and says when the site is out of its reach.</p>
<p>A plain <b>FTP</b> host -- the kind a shared hosting plan gives you, with no SSH to it at all -- goes through rclone as well. In <code>rclone config</code> pick the type <code>ftp</code> and give it the host, the user and the password; then</p>
<pre class="code-block"><code class="language-bash">export DEPLOY_BACKEND=rclone
export RCLONE_TARGET=myftp:public_html   # remote name : the directory the host serves</code></pre>
<p>Nothing else is needed: the engine tells rclone exactly which files to send and which to delete, so it never asks the server for checksums, which FTP cannot give. If the host offers FTPS, turn it on in <code>rclone config</code> (<code>explicit_tls</code>) -- plain FTP sends the password in the clear.</p>
<p>If the deploy fails with the server complaining about too many connections -- shared hosting often allows only a few per account -- make rclone open one at a time:</p>
<pre class="code-block"><code class="language-bash">export RCLONE_ARGS=&quot;--checkers 1 --transfers 1&quot;</code></pre>
<p>rclone also has <code>--ftp-concurrency</code>, but its own documentation warns that setting it is very likely to deadlock unless it is one more than <code>--checkers</code> and <code>--transfers</code> together; the two flags above are the simpler way to the same limit.</p>
<h2 id="sftp-hosts-with-neither-rsync-nor-git">sftp (hosts with neither rsync nor git)</h2>
<pre class="code-block"><code class="language-bash">export DEPLOY_BACKEND=sftp
export SFTP_TARGET=user@server
export SFTP_REMOTE_DIR=/var/www/mysite   # optional; nested paths must exist
export SFTP_ARGS=&quot;-P 2022&quot;               # optional</code></pre>
<p>Uses openssh&#39;s <code>sftp</code> in batch mode -- one connection uploads exactly what the manifest says changed. Set up SSH key auth first; a password prompt would block the batch.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>Running on a server</title>
  <link>https://manual.blogsh.app/posts/2026/running-on-a-server/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/running-on-a-server/</guid>
  <pubDate>Mon, 12 Oct 2026 11:06:00 +0200</pubDate>
  <description><![CDATA[<p>The engine runs wherever the content lives -- typically either <b>on your laptop</b> (deploying to a remote target) or <b>on the server itself</b> (SSH in to write; this is how the reference deployment on sean.cz works, inside a Cloudron/Docker container). For the server variant:</p>
<ol><li>Clone the repo on the server, repeat steps 2-6 there. <code>env.sh</code> stays on the server only -- it never syncs anywhere.</li><li>Install the widget cron (see <a href="/posts/2026/cron-sidebar-widgets-and-post-stats/">Running it</a>).</li><li>For writing from a phone, prepare the <code>incoming/</code> staging directory: any SSH/SFTP account that can write into <code>&lt;repo&gt;/incoming/</code> works. A dedicated user without sudo, limited to SFTP, is the safe shape -- photos are uploaded there by name and referenced as bare filenames in posts (see <a href="/posts/2026/writing-from-a-phone/">Writing</a>).</li><li>For a post sent whole from a phone, add the key the shortcut uses to <code>authorized_keys</code> with a forced command pointing at <code>scripts/receive.sh</code> -- the line, the RSA requirement and the size ceiling are in <a href="/posts/2026/writing-from-a-phone/#a-post-sent-from-the-phone-itself">Writing</a>. Nothing new listens on the network; it travels over the SSH the server already has.</li><li>In a container setup (Cloudron and similar), the engine lives inside the container&#39;s persistent data directory and commands run via <code>docker exec</code> / the platform&#39;s terminal -- the engine itself doesn&#39;t care, it&#39;s just Ruby + bash in a directory.</li></ol>]]></description>
  <category>install</category>
</item>
<item>
  <title>Comments network (optional): Mastodon or Bluesky</title>
  <link>https://manual.blogsh.app/posts/2026/comments-network-optional-mastodon-or-bluesky/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/comments-network-optional-mastodon-or-bluesky/</guid>
  <pubDate>Mon, 12 Oct 2026 11:05:00 +0200</pubDate>
  <description><![CDATA[<p>Every published post is announced on the configured network, and replies to that announcement are the post&#39;s comments. A reply&#39;s pictures appear under its words -- thumbnails linking out to the full image, loaded from the commenter&#39;s own network the same way their avatar already is; a reply marked sensitive keeps its pictures to itself. Configure <b>exactly one</b> of the two -- the build refuses a config with both, since a discussion split across two networks serves nobody. Without either section, everything else still works; publishing just skips the announcement (logged, not an error).</p>
<p><b>Mastodon:</b></p>
<ol><li>In <code>config/site.yml</code>, uncomment the <code>mastodon:</code> block -- the header line included, which is the half that is easy to miss -- and set <code>mastodon.instance</code>. This switches on comments, per-post stats and the auto-toot on publish.</li><li>On that instance: Preferences → Development → New application, scope <code>write:statuses</code>. Put the token into env.sh as <code>MASTODON_ACCESS_TOKEN</code>.</li><li>For the &quot;recent toots&quot; sidebar widget, <code>widgets.toots.account_id</code> wants the <i>numeric</i> account id, not the @handle -- find it at <code>https://&lt;instance&gt;/api/v1/accounts/lookup?acct=&lt;username&gt;</code>. (The widget&#39;s instance falls back to <code>mastodon.instance</code>.)</li><li><code>mastodon.toot_length</code> is your instance&#39;s character limit, 500 by default. Set it if yours differs -- the announcement&#39;s perex is budgeted against this number, so a limit set too high gets the post rejected at publish time and one set too low just wastes room. <code>mastodon.link_length</code> is the other half of that arithmetic: Mastodon charges every link a flat 23 characters whatever it measures, and says so in its own API (<code>characters_reserved_per_url</code>). Leave it alone unless your server answers with a different number.</li></ol>
<p><b>Bluesky:</b></p>
<ol><li>In <code>config/site.yml</code>, set <code>bluesky.handle</code> (e.g. <code>you.bsky.social</code>); <code>bluesky.pds</code> only if you self-host a PDS.</li><li>On Bluesky: Settings → Privacy and security → App Passwords (bsky.app/settings/app-passwords) → create one, and put it into env.sh as <code>BLUESKY_APP_PASSWORD</code> -- never the account password.</li><li>Announcements fit Bluesky&#39;s 300-grapheme limit automatically (the excerpt shrinks; title, link and hashtags never do), with the link and hashtags clickable. Comments are read from Bluesky&#39;s public AppView by the visitor&#39;s browser -- no token involved on the page.</li></ol>
<p><b>Optional: publish only the replies you star.</b></p>
<p>By default every reply to the announcement appears under the article. <code>comments.approval: fav</code> in <code>config/site.yml</code> changes that to: a reply appears once <i>you</i> favourite it, from whatever Mastodon or Bluesky client you already use. Your own replies need no star. Everything else stays on the network and off the blog.</p>
<pre class="code-block"><code class="language-yaml">comments:
  approval: fav      # or &quot;off&quot; (the default)</code></pre>
<p><b>The moment you switch this on, every comment already on the site disappears.</b> Moderation publishes only what you have starred, and at that moment that is nothing -- a site with years of replies goes quiet in one rebuild. They come back one star at a time, so plan the switch for a moment when you can go and star the keepers right away, and run <code>./scripts/refresh-sidebar.sh --full</code> once afterwards to settle old posts on the spot instead of within the week.</p>
<p>Three things have to be true for it to work, and <code>./blog.sh doctor</code> (add <code>--online</code> for the token check) says so when they aren&#39;t:</p>
<ol><li><b><code>scripts/refresh-sidebar.sh</code></b><b> has to be in cron</b> (see <a href="/posts/2026/cron-sidebar-widgets-and-post-stats/">Running it</a>). &quot;Did <i>I</i> favourite this?&quot; is a question only an authenticated request can ask, and the token can&#39;t be shipped to a browser -- so cron reads the thread and writes <code>public/comments.json</code>, and the page renders from that. Without that cron job nothing new ever appears.</li><li><b>The token needs </b><b><code>read:statuses</code></b> alongside <code>write:statuses</code>. On Mastodon that is a choice; on GoToSocial it is the only way comments work at all (see the note below). Reissue it under Preferences → Development if yours predates this: a token without it gets a perfectly good answer with the <code>favourited</code> field left out of it, which reads as &quot;approved nothing&quot;. On Bluesky the existing app password is enough.</li><li><b>You have to go and star the comments worth keeping</b> -- see the warning above the list.</li></ol>
<p><b>GoToSocial.</b> Live comments cannot work there, whatever you configure: every read of a thread needs a token, all four of its authentication requirements are on, and a token cannot be put in a visitor&#39;s browser. Turn on <code>comments.approval: fav</code> -- the cron reads the thread with the token and only the replies you star reach the page. Two more things worth knowing: the token is made under Settings → Applications → New Application (not Preferences → Development, which is Mastodon&#39;s path and does not exist here), with the scopes written space-separated -- it needs <code>read:statuses</code> as well as <code>write:statuses</code>, and both fit in one token because GoToSocial has granular scopes. And a GoToSocial instance usually allows 5000 characters rather than 500, so <code>mastodon.toot_length</code> is worth setting to whatever your server&#39;s <code>statuses-max-chars</code> says. <code>doctor --online</code> checks the first of these for you and says so in one sentence.</p>
<p>Worth knowing before switching it on:</p>
<ul><li>Approval is not instant -- up to one cron interval, and for a post older than ~90 days up to a week, since old posts are only refreshed occasionally. <code>./scripts/refresh-sidebar.sh --full</code> does it now.</li><li>A favourite is public on both networks. Anyone can see which replies you starred, and replies starred in the past count as approved.</li><li>The side effect nobody asks for and everybody gets: with the thread already read server-side, the visitor&#39;s browser stops contacting the network at all, and the page&#39;s CSP drops its <code>connect-src</code> grant to it. Avatars are still loaded from the instance hosting them.</li><li>It keeps replies off the blog. It cannot remove them from the network, where the thread stays public and this site still links to it. Block, mute and report remain the tools for that.</li></ul>]]></description>
  <category>install</category>
</item>
<item>
  <title>Updating the engine</title>
  <link>https://manual.blogsh.app/posts/2026/updating-the-engine/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/updating-the-engine/</guid>
  <pubDate>Mon, 12 Oct 2026 11:04:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">git pull
./blog.sh rebuild</code></pre>
<p><code>rebuild</code>, not <code>ruby build/build_blog.rb</code>: the build script renders one language per run, and <code>rebuild</code> runs it for every language the site publishes and then deploys. On a site in two languages the bare script followed by a deploy leaves the second language&#39;s pages as the previous version built them -- and uploads them that way.</p>
<p><code>main</code> is the release branch: a clone on it gets each release by <code>git pull</code>. A clone that was put on a release tag (<code>git checkout v1.8</code>) is on no branch, and <code>git pull</code> refuses with &quot;You are not currently on a branch&quot;; move it to the next release by name instead:</p>
<pre class="code-block"><code class="language-bash">git fetch --tags &amp;&amp; git checkout v1.9</code></pre>
<p>The first build after an upgrade may be a full one -- the engine&#39;s own fingerprint changed, so the record of the last build is thrown away -- and it writes <code>.build_cache.json</code> in the installation directory, which is per-machine, gitignored and always safe to delete. Deploy the assets with it: a page that cannot fetch <code>assets/js/share.js</code> logs a 404 on every load.</p>
<p><b>Going back a version</b> is <code>git checkout &lt;tag&gt;</code> and a rebuild, and what the newer version was given to work with is looked at first, because the older one does not treat all of it alike. From 1.10 back to 1.9: take <code>series:</code> and <code>language_name:</code> out of every <code>config/site.&lt;lang&gt;.yml</code>, or the build stops on them and nothing is deployed -- a language&#39;s file is where 1.9 refuses a key it does not know. <code>on_this_day</code> under <code>widgets:</code> it does not refuse: the build says one line about it, <code>doctor</code> marks it, and the card is simply not drawn. And <code>site.author_name</code>, <code>banner.show_image</code>, <code>layout.lead_card</code> and <code>publishing.allow_partial</code> it passes over without a word, so the pages go back to what 1.9 makes without them -- the banner&#39;s picture is shown again, for one, and a site of several languages is asked about every post again. Old addresses a translation was given (<code>translations.&lt;lang&gt;.former_slugs</code>) stop being answered -- 1.9 does not read them, and a translation saved there is written without the list, so those addresses are not in the post on the way forward again either. What 1.10 left in the installation folder for itself (<code>.on-this-day.*.json</code>, <code>.search_index.json</code>, <code>.last-sidebar-run</code>) is safe to delete. The posts, the media and the rest of the configuration are otherwise untouched in both directions.</p>
<p>Per-deployment files (<code>content.nosync/</code>, <code>media.nosync/</code>, <code>config/site.yml</code>, <code>config/site.&lt;lang&gt;.yml</code>, <code>env.sh</code>, <code>incoming/</code>, <code>trash/</code>, <code>drafts/</code>, manifests) are gitignored and survive any pull untouched. The one thing to watch: if you&#39;ve <b>edited engine files in place</b> (templates, CSS), a pull can conflict -- keep such customizations as commits on your own branch so git merges them for you. <code>config/palettes.yml</code> is one of those: it ships with the engine, so a palette of your own added to it is an edit in place. The wizards also leave a <code>.bak</code> of whatever they rewrote -- <code>config/site.yml.bak</code>, <code>env.sh.bak</code> -- which is how you take a wizard run back by hand. Nothing deletes them, and the <code>env.sh</code> one holds your previous tokens at the same 0600, so remove it once you have rotated a token.</p>]]></description>
  <category>install</category>
</item>
<item>
  <title>Writing and publishing</title>
  <link>https://manual.blogsh.app/posts/2026/writing-and-publishing/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/writing-and-publishing/</guid>
  <pubDate>Mon, 12 Oct 2026 11:03:00 +0200</pubDate>
  <description><![CDATA[<p>Day-to-day usage of an installed site: writing, deploying, cron, backup and what to do when something fails. For the zero-to-deployed path see <a href="/series/getting-started/">Getting started</a>.</p>
<p><code>./blog.sh</code> with no arguments opens a numbered menu; every command also works directly (<code>./blog.sh add</code>, <code>edit</code>, <code>publish</code>, ...). The flow is built around drafts:</p>
<ol><li><code><b>add</b></code> opens <code>$EDITOR</code> with a frontmatter template (title, tags, type) -- the in-editor hint links to the <code>/markdown/</code> syntax reference on your own site. Saving always creates a <b>draft</b>, and the draft appears at a hidden <code>/draft/&lt;token&gt;/&lt;slug&gt;/</code> address with <code>noindex</code> -- invisible in every listing, shareable by URL (that&#39;s the point: open the preview on a phone or send it to someone before publishing).</li></ol>
<p><b>Saving a draft builds the site and deploys it.</b> Not only the draft&#39;s    own page: the deploy is the ordinary one, so everything the build    produced goes to the target, the front page included. It has to -- the    preview is a real address on the real site, and so is the answer a    phone goes and asks for. There is no way to keep a draft off the    target while its preview is expected to work. See    <a href="/posts/2026/pick-a-deploy-target/">Getting started → Pick a deploy target</a>    for what that means for a target that already holds something. 2. The CLI then asks: <b>publish / schedule / keep as draft / back to    editing.</b> Publishing sets the date to that moment (scheduling asks    for one instead), moves the post to its real URL and -- with a comments    network configured (Mastodon or Bluesky, see    <a href="/posts/2026/comments-network-optional-mastodon-or-bluesky/">Getting started</a>) --    sends the announcement post that replies-as-comments hang off. 3. <code><b>edit &lt;slug&gt;</b></code> round-trips the stored post back to Markdown in    your editor. A save that would drop content markdown can&#39;t express    (an imported embed, a link card) warns and asks before proceeding.</p>
<p>A line reading <code>//--more--//</code> splits a post in two: what it says about    itself, and what it actually says. The listing card, the link card and    the announcement take the first half; the post&#39;s own page shows    everything. Without the marker a card is cut to fit a size budget,    which lands wherever the budget runs out rather than on a sentence you    chose. 4. <code><b>unpublish &lt;slug&gt;</b></code> returns a post to draft and deletes its    announcement on the network (an announcement pointing at a dead URL    helps nobody). The next publish gets a fresh date.    <code><b>delete &lt;slug&gt;</b></code> moves the post and its media to <code>trash/</code> --    <code><b>restore &lt;slug&gt;</b></code> brings it back. Trash keeps only the most    recent deletion per slug.    The same slug can exist in several years (backdating makes that    easy); every slug-addressed command -- <code>edit</code>, <code>delete</code>, <code>toot</code>,    <code>publish</code>, ... -- then first lists the matching posts (date, type,    state, title) and asks which one you mean, so a delete can&#39;t    silently land on the older post. A number picks, anything else    cancels. 5. <code><b>toot &lt;slug&gt;</b></code> (Mastodon sites) or <code><b>bluesky &lt;slug&gt;</b></code> (Bluesky    sites) (re-)sends the announcement for an already published post --    typically an imported one that never had one. An existing    announcement is never overwritten.</p>
<p><b>A post&#39;s header can say more than the template offers.</b> <code>add</code> prefills title, tags and type, because that is what every post needs; the parser accepts a few more keys, and <code>edit</code> brings back whichever ones the post already carries, so each can be read as well as changed:</p>
<ul><li><code><b>series: Name</b></code> files the post into a series. A series with two or more published parts gets a listing of its own at <code>/series/&lt;slug&gt;/</code> -- a series of one is just a post, and the listing appears when the second part does -- and with it a line in the index of series at <code>/series/</code>. The listing reads from part one: a long series is paged in reading order, <code>/series/&lt;slug&gt;/</code> holds the first parts and <code>/series/&lt;slug&gt;/page/2/</code>, <code>/page/3/</code>… the ones after, so the &quot;part 1 of 30&quot; link on every post opens where the series starts. (Up to 1.7 a series of twenty parts or more opened at its last page instead, and its <code>/page/N/</code> addresses held other parts than they do now.) A series whose name is too long to fit an address gets no listing; its parts still say which part they are, without a link. The build puts the series&#39; name and &quot;part 2 of 5&quot; on each post in it -- and on each card of the series&#39; own listing -- with the way to the part before and the part after -- within the series only, since a post&#39;s chronological neighbours across a whole archive are rarely what a reader wants next. Parts are ordered by date, which is what a series written in order needs; <code><b>series_part: 3</b></code> is for the one written out of order or inserted afterwards. The draft preview answers the series question at writing time: a name with published parts shows how many and which part this would become, and a name no published post carries yet is called out as &quot;a typo, or the first part?&quot; -- so a misspelling doesn&#39;t quietly found a second series. <code>check</code> catches the older ones (<a href="/posts/2026/checking-the-archive/">below</a>).</li></ul>
<p>A number is a claim on a slot, not a label: the part is inserted at the   position it names among the ones ordered by date, so a series whose   numbers don&#39;t add up -- two parts both told <code>2</code>, a number past the last   part -- gives one of them a different place than it asked for. <code>props</code>   says so where it happens, showing the number the post carries and, after   it, the place the page will actually give it. Where the two agree, which   is the ordinary case, only the number is shown. - <code><b>link:</b></code> gives the post a link card: the address it is about, drawn   above the text. <code>link_title:</code> and <code>link_description:</code> are the words on   it, and a post with no <code>title:</code> of its own is named by the card. It is a   header key rather than a line in the body because a paragraph that is   only a link already means something else -- an ordinary link in ordinary   prose -- and because the card belongs to the post, not to a paragraph of   it. <code>edit</code> writes all three lines back out, so a save cannot drop a card   the author never touched. The address is a whole <code>http://</code>/<code>https://</code>   one, or one rooted at this site (<code>/posts/2026/some-post/</code>) for a card   about another post here -- which is also the shape <code>check --repair</code>   leaves behind when it straightens a relative link out of an import. - <code><b>publish: yes</b></code> publishes the post the moment <code>add &lt;file&gt;</code> writes   it -- the date settled, the announcement sent, the site rebuilt -- the   road <code>publish &lt;slug&gt; --yes</code> takes at a desk. Only the file route reads   it; the wizard never publishes directly. Absent, or anything but   yes/true/1, is a draft. - <code><b>edits: &lt;slug&gt;</b></code> makes the file not a new post but a new text for   that post -- the save <code>edit</code> does, with every question it would ask   turned into a refusal, because nobody is there to answer it. <code>base:</code>   beside it is the digest <code>./blog.sh drafts --json</code> or <code>edit &lt;slug&gt;   --json</code> handed out with the text; when the post has changed since, the   file is refused (<code>changed</code>) instead of undoing whatever happened in   between. A draft may come without <code>base:</code>; a published post may not   (<code>base_required</code>), and saving one rebuilds and deploys. What the new   text leaves out is taken out: a paragraph, a picture or a rule its   author removed is the edit, and a picture the text stops naming is   deleted from the post&#39;s media. A picture the text names by a name the   post already has is the post&#39;s own file -- so a new one sent under   that name is not used (the answer says so in <code>warnings</code>); a picture is   replaced by sending the new one under a name of its own. Taking everything out is not an edit: a   file with no text under its header is refused (<code>empty</code>), as a new one   is, because that is also what a program sends when it never had the   text -- of a post that had a body; one that is a title and its tags, or   a link card, which travels in the header, has none to begin with, and is   saved. What the text leaves as it was is saved   as it was stored -- the very block, with whatever an import put on it   that markdown cannot say (a paragraph&#39;s kind, a video&#39;s provider) -- so   an edit changes what was edited and nothing else. A block the text did   change is made again from the text, and keeps what the text could not   have said about it -- the kind an import gave a paragraph, the address a   video was first at -- where the save can tell which stored block it   rewrote: between two blocks kept as stored, the rewritten blocks are   paired in order with the stored ones of their kind when there are as   many of each. A paragraph inserted beside a corrected one makes two for   one, and both are saved as written. What is refused as a loss   (<code>content_lost</code>) is what the author never saw going, and it is one of   two things. A block the text has no line for at all -- a second link   card, a player from somewhere the engine does not know -- is lost by any   save of that text: such a post is marked <code>editable: false</code> when its   text is handed out, and editing it is for the desk, where <code>edit</code> asks.   And a block the edit rewrote may have held more than its words -- a   mention an import made of <code>@somebody</code>, small print: that edit is   refused too, naming the thing, while the same post edited anywhere   else is saved, with that block as it stood. This   is how an app saves a post it opened (see   <a href="/posts/2026/writing-from-a-phone/#editing-a-draft-from-the-phone">Writing from a phone</a>), and a way for   a script to edit without the <code>$EDITOR</code> trick. - <b><code>toc: true</code></b><b> / </b><b><code>toc: false</code></b> overrides the table of contents. A post   with four headings or more gets one on its own -- that is the length at   which a reader starts scrolling to look for something rather than   reading down. Say <code>false</code> on a post that has the headings but not the   shape, <code>true</code> to force one below the threshold. - <b><code>hero: true</code></b><b> / </b><b><code>hero: false</code></b> decides whether the post&#39;s first usable   image runs full width above the title, with the date moving into a   byline under the picture. Without the key the post follows   <code>layout.hero</code> for the whole site   (<a href="/posts/2026/configure-the-site-config-site-yml/#the-menu-the-regions-and-a-stylesheet-of-your-own">Getting started</a>).   A 1px image is never lifted, and a post with no usable picture simply   keeps the ordinary shape -- there is no second template to keep in step.</p>
<p><b>Two things the site says without being asked.</b> Every card and every post page carries how long the post takes to read, counted at 200 words a minute -- the same figure <code>./blog.sh stats</code> reports; a post too short to time says &quot;under a minute&quot;. And the build writes a <code>noindex</code> <code>/404.html</code> in the site&#39;s own chrome, menu and search field included, so a dead end is somewhere to go on from. Under its sentence stand a search field of its own -- filled, where the browser runs scripts, with the words of the address that was not found -- and pills back to the front page, to the archive and to the index of tags; an address that names a year the archive has gets a pill straight to that year. The page is one file for every missing address, served by your host: it is written in the site&#39;s first language whatever language the address was in.</p>
<p><b>Backdating</b> isn&#39;t part of that flow -- publishing means &quot;now&quot; -- but the frontmatter parser still honors a <code>date:</code> line you type in by hand (the template just doesn&#39;t offer one). To publish a draft into the past:</p>
<pre class="code-block"><code class="language-bash">./blog.sh edit muj-post      # add a line between the --- markers:  date: 2019-11-17 10:00
./blog.sh publish muj-post   # -&gt; &quot;Date kept from frontmatter: Nov 17, 2019 10:00&quot;</code></pre>
<p>The date takes any format <code>Time.parse</code> reads and is interpreted in <code>site.timezone</code>. The post&#39;s URL year follows the date -- the JSON and the media directory move into that year -- and the auto-announcement asks for confirmation first when the date is more than a day off. A backdated post is ordered by its date, so it can skip the homepage and RSS entirely and land straight in the archive; the CLI says so when it happens. If another post already owns that year/slug combination, publishing refuses rather than overwrite it. (<code>schedule</code> is the opposite direction on purpose: it refuses past dates.)</p>
<p><b>Scheduled publishing:</b> in the post-save dialog, choose <code>[s]</code> and enter the publish date and time directly -- the <a href="/posts/2026/cron-sidebar-widgets-and-post-stats/">publish-scheduled cron</a> then publishes the draft (toot included) once that date arrives, keeping it as the post&#39;s date. The standalone <code>./blog.sh schedule &lt;slug&gt;</code> asks the same question, so either route works. The time you type is read in <code>site.timezone</code> (<a href="/posts/2026/configure-the-site-config-site-yml/">Getting started</a>) -- worth setting before you schedule anything from a server, whose clock is usually UTC. A past date is refused (that would just mean &quot;publish now&quot;, and <code>publish</code> is for that). Running <code>schedule</code> on an already scheduled draft cancels it; <code>list</code> shows scheduled drafts as <code>[SCHEDULED]</code>.</p>
<p>Two kinds of post are published by that cron without being announced, and it says so per post, naming <code>./blog.sh toot &lt;slug&gt;</code> for sending the announcement by hand: a post dated more than a day from now (a backfill -- an old thread given a page, a post imported and then queued -- reads as news in a live timeline), and a post that already carries an announcement of its own, which happens when it was published once, unpublished and put back in the queue. Announcing that one again would leave the first thread live with its replies while the post pointed at a second, empty one. Several posts falling due in the same tick -- after the cron has been down, say -- are published oldest first, in the order the queue was arranged in.</p>
<h2 id="in-the-terminal">In the terminal</h2>
<p>The CLI adapts to where it runs. In an interactive terminal you get arrow-key menus (digits still quick-select, typing a slug still works), single-keypress answers without Enter, colored state markers and a <b>QR code of the draft preview URL</b> -- after a save, and on <code>[q]</code> in a draft&#39;s properties -- point your phone&#39;s camera at the screen instead of retyping a token. A menu longer than the terminal is tall scrolls, showing your position in the list next to the hint; <code>Page Up</code>, <code>Page Down</code>, <code>Home</code> and <code>End</code> work in every list.</p>
<p><b>The screens hold still.</b> Menus, the queue, the archive, a post&#39;s properties and every picker repaint over themselves instead of printing another copy on each keypress -- and not on the alternate screen, so the last screen and your scrollback survive. Resizing straightens a waiting menu, the queue or the properties screen on the spot; a picker or the archive browser keeps the size it opened with until you leave it.</p>
<p><b>Enter and Esc mean the same everywhere.</b> Enter takes what the question offers -- the capital letter in <code>[Y/n]</code>, the named <code>[Enter] …</code>, the row under the cursor. Esc leaves without doing anything: it never builds, deploys, opens the editor or writes a file, and in a question about a setting it keeps the current value. A question you type an answer into is read a whole line at a time, so there it is Esc and then Enter: the current value stays, and &quot;publish when?&quot; is left without a date.</p>
<p>The three question-and-answer wizards -- <code>./setup.sh</code>, <code>./style.sh</code> and <code>./import.sh</code> -- keep the section you are in and the answers already given above the question, so a half-finished run says where you are.</p>
<p>Piped, scripted or cron runs get the plain line-based prompts unchanged, with no escape codes in the output. Colors honor <code>NO_COLOR</code> and <code>TERM=dumb</code>.</p>
<p><code>./blog.sh preview [&lt;port&gt;]</code> serves the built site locally (default port 8000) when you want to look at it without deploying.</p>
<h2 id="properties-and-actions">Properties and actions</h2>
<p><code>./blog.sh props &lt;slug&gt;</code> (in the wizard: pick a post, then <code>v</code>) shows everything about one post in one place -- state, type, tags, the pin, the announcement -- and offers the guarded actions:</p>
<ul><li><b>published</b>: unpublish, (re-)announce, pin/unpin, rename the slug, review the old addresses that redirect here, delete;</li><li><b>draft</b>: publish (asked first -- with a network configured it announces too, which nothing takes back), schedule (or reschedule, or cancel the schedule), rename the slug, delete -- and the screen names the draft&#39;s preview address, with <code>[q]</code> for its QR code, so a draft can be read on a phone without opening it in the editor first;</li><li><b>either</b>: <code>[e]</code> opens the post&#39;s properties -- which series it is in and which part of it, its tags, its type, and the three flags (out of the listings, lead image, chapter list);</li><li><b>either, once there is one</b>: <code>[v]</code> restores what the post said before one of its recent saves.</li></ul>
<p><b>Properties are what the post IS, not what it says</b>, and until 1.7 the only way to change one was <code>edit</code> -- the whole article open in an editor to add a series somebody forgot. Worse, on a post whose blocks markdown cannot all write down (an imported embed, a link card that is not the first block) that edit asks whether it may drop them, which is a great deal of risk for one word of metadata.</p>
<p><code>[e]</code> writes each answer as it is given and rebuilds once on the way out. Two of the rows are pickers rather than prompts, on purpose: the series row lists the series the site already has, with how many posts carry each, because a series typed a second time is a second series and <code>check</code> only notices afterwards -- and the type row lists the eight the engine knows plus the way back to letting the content decide, which is what a post with no <code>type</code> of its own has always done. The tags row prints the site&#39;s most-used tags above the line it asks for, for the same reason.</p>
<p>The lead image and the chapter list have three states, not two: on, off, and whatever the site does -- which is what a post that says nothing about them takes, and a different thing from saying no.</p>
<p><code><b>props &lt;slug&gt; --json</b></code> prints the same screen as one object and exits: state, date, address, type, tags, series, the flags, which languages the post is written in, whether and where it was announced (<code>announces</code> says which of the six cases it is), the old addresses -- and <code>actions</code>, the keys the screen would offer this post, by name (<code>publish</code>, <code>schedule</code>, <code>unschedule</code>, <code>unpublish</code>, <code>announce</code>, <code>pin</code>, <code>properties</code>, <code>rename</code>, <code>addresses</code>, <code>versions</code>, <code>delete</code>). A program reads that instead of re-deriving the screen&#39;s rules, then calls the command it wants. A slug nobody has, or one that lives in two years, is refused as an object (<code>&quot;ok&quot;: false</code>, <code>not_found</code> or <code>ambiguous_slug</code>) with a zero exit, the way <code>add --json</code> refuses; the slug is required, because an object for an answer means nobody is there to pick one.</p>
<p><b>Undoing an edit</b> is what <code>[v]</code> is for. Every <code>edit</code> and every <code>translate</code> keeps the previous text first, up to ten of them per post, and <code>[v]</code> lists them newest first with the line under the cursor showing what that version said -- its title, or its opening words when it has none -- so the choice is made by recognising the text rather than by reading timestamps. The key appears only on a post that has been edited at least once.</p>
<p>Choosing one is itself undoable: the current text is kept as a version before it is replaced, so a wrong choice is one <code>[v]</code> away from being walked back -- which is why a single key confirms it. Only the text comes back -- images are not versioned, and the line above the list says so. The text of every language the version holds comes back with it; a language the post was given since is left as it is, and so is the address each language serves the post at. A version that will not parse stays in the list without a preview. Versions travel with the post into the trash and back out again.</p>
<p>Type and tags are shown here but <i>edited</i> in the frontmatter of <code>edit</code>, prefilled with their current values -- one keystroke away from the text they describe. The pin is the exception: it is a switch, not a value, so <code>[c]</code> flips it right here (the <code>pinned:</code> header line keeps working too). The pinned post is also marked <code>[PINNED]</code> in every list and picker, so it can be found without remembering it. A plain draft shows no time on purpose: a draft has none until publishing or scheduling gives it one.</p>
<p><b>Keeping a post out of the listings</b> is the <code>unlisted: true</code> line in its frontmatter. The post keeps its ordinary address, its date and its redirects, and drops out of the homepage, the tag and type archives, the feeds, the sitemap and the search index; its page is <code>noindex</code>. It is the draft&#39;s hidden address, generalised to something finished -- for a post meant for the few people you send the link to. <b>It is not a password</b>: a static site hands over whatever is asked for, so anyone with the link can read it and forward it. If that is not good enough, the answer is not to publish it. Publishing an unlisted post announces nothing -- by hand or from cron, and <code>--force</code> does not open that door, because an announcement cannot be recalled once a server has it; to announce the post, take the flag off first. <code>props</code> says so on an unlisted post, and the line comes back with its current value the next time you edit, so the state can be read as well as changed.</p>
<p><b>Renaming a slug</b> never breaks a link. The old address stays on the site as a one-page redirect to the new one, recorded in the post itself (<code>former_slugs</code>), so it survives edits, re-imports and full rebuilds. A rename costs one extra page per old address -- not a 404. Two things to know: unpublishing takes the redirects off the site along with the post (they return when it does), and deleting the post deletes its old addresses&#39; redirects with it. Renaming a draft is free -- nothing is published yet -- but its preview URL changes, so share the new link. One consequence the redirect can&#39;t cover: feed readers identify posts by their URL, so a renamed post may appear once more as a new item in subscribers&#39; readers. The redirect keeps every clicked link working; what a reader app shows is its own business.</p>
<p><b>Moving a post to another year</b> -- editing its date across a New Year -- moves its public address the same way a rename does, and records the old one the same way. The link from before the edit keeps working.</p>
<p><b>Old addresses can also be given up</b>, with <code>[a]</code> in the same dialog: it lists every address that redirects here and drops the one you pick. There is one situation where that is the only cure rather than a preference: if a NEW post has since taken an old address, the build refuses to overwrite a live page with a redirect stub -- correctly -- and says so on every build. The entry can never do anything again, and <code>[a]</code> marks exactly that entry as &quot;taken by another post&quot;. Dropping any other one is a decision, not a repair: that link stops working for good.</p>
<p>The wizard menu lists six activities -- a new post, a post in hand, the scheduled-post queue, the archive browser, the trash and a rebuild -- not every command: publish, schedule, unpublish, delete and the announcement live in this dialog (and the draft dialog) instead of being menu items. Every CLI command still exists unchanged -- <code>./blog.sh unpublish &lt;slug&gt;</code> works exactly as before; only the menu stopped listing it.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Writing a post in another language</title>
  <link>https://manual.blogsh.app/posts/2026/writing-a-post-in-another-language/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/writing-a-post-in-another-language/</guid>
  <pubDate>Mon, 12 Oct 2026 11:02:00 +0200</pubDate>
  <description><![CDATA[<p>Only on a site that publishes more than one -- <code>site.locales</code> in <a href="/posts/2026/configure-the-site-config-site-yml/">Getting started</a> is what says so.</p>
<pre class="code-block"><code class="language-bash">./blog.sh translate my-post --lang de</code></pre>
<p>Or from the wizard, which is where the rest of the work happens: pick the post, and the crossroads that offers its text and its properties offers the languages too. With one other language it names it (<code>[l] language: Deutsch</code>); with several it opens a picker that says what is already written, what is only started, and what has nothing yet -- the same three states <code>check --languages</code> prints. The properties screen carries a <code>languages</code> row for the same reason. (As data the two say them in different words: <code>props --json</code> has <code>written</code>, <code>started</code> and <code>none</code>, <code>check --languages --json</code> has <code>written</code>, <code>title_only</code> and <code>missing</code>.)</p>
<p>The editor that opens holds a title and a body, and nothing else. That is the whole of what a translation may carry: the date, the tags, the series, the pin and the state belong to the POST and are true in every language at once, so they are changed with <code>props</code> or <code>edit</code> and never here. A translation cannot quietly disagree with the post it belongs to.</p>
<p>Save it and <code>./blog.sh rebuild</code> builds every language the site publishes: the language in <code>site.lang</code> at the site root, the others under <code>/de/</code>, all in one tree with one copy of <code>/assets/</code> between them.</p>
<p>The header also holds the address that language serves the post at. Left alone it is made from the translated title, once, and then stays put -- see <a href="/posts/2026/publishing-in-more-than-one-language/#the-address-in-each-language">Localization</a>.</p>
<p>The post&#39;s own words come along as <code>//</code> lines the first time, so the text being translated is in front of you rather than in another window. The editor drops every <code>//</code> line on save, which is also why a translation left half-done cannot save the original as though it were this language&#39;s.</p>
<p>What a translation&#39;s text does not say is kept, as it is for the post&#39;s own: a block the new text left alone is saved as it was stored, with a paragraph&#39;s kind or a video&#39;s player still on it, and a block the text rewrote keeps them too. What the text cannot carry at all -- a link card, a player with no address to write -- is asked about before it goes; from a file it is refused (<code>content_lost</code>), and <code>translate --json</code> marks such a translation <code>editable: false</code> beforehand.</p>
<p><b>Publishing a post the site cannot show in every language it publishes</b> is a decision, not an accident: the command line refuses it and names <code>--allow-partial</code>, and the wizard asks -- write the missing language now, or publish it as it stands. A site that has made that decision for every post says <code>publishing.allow_partial: true</code> in <code>config/site.yml</code>, and is neither refused nor asked (<a href="/series/localization/">Localization</a>).</p>
<p><b>Taking a language off a post</b> is emptying the title and deleting the body. The post then looks exactly as it did before the translation existed -- which matters, because a half-empty translation would otherwise give that language a page with the wrong words on it. Two things of it are kept: the text as it was, as a version <code>[v]</code> restores, and the old addresses that text was owed, which go to the post&#39;s own list.</p>
<p><b>An address that belonged to a translation</b> is kept with it. When two posts that were the same piece in two languages are made one -- the English post becomes the Czech post&#39;s <code>translations.en</code> -- the English post&#39;s address is still out on the web. Put into the post&#39;s own <code>former_slugs</code> it would lead to the post, which at the site&#39;s root is the Czech page. Written into the translation instead, it leads to the translated one:</p>
<pre class="code-block"><code class="language-json">&quot;translations&quot;: { &quot;en&quot;: { &quot;title&quot;: &quot;…&quot;, &quot;slug&quot;: &quot;the-old-slug&quot;, &quot;content&quot;: [ … ],
                          &quot;former_slugs&quot;: [&quot;2019/the-old-slug&quot;] } }</code></pre>
<p><code>/posts/2019/the-old-slug/</code> then redirects to <code>/en/posts/2019/the-old-slug/</code>.</p>
<p>The merge has two more steps, and <code>check</code> between them speaks of neither. The English post is still in the archive, at the very address the list now names -- so <code>check</code> says the address is taken by a live post and offers to drop the entry or rename that post. Neither: the English post is what goes, <code>./blog.sh delete the-old-slug</code>, and the entry is served from then on. Before it goes, copy any picture only it had into the media folder of the post that stays (<code>media.nosync/&lt;year&gt;/&lt;slug&gt;/</code>), under a name the translation&#39;s blocks then use: <code>delete</code> takes a post&#39;s media to the trash with it, and a translation shows files from the folder of the post it belongs to. Deleting a published post also takes its announcement down where the network allows it, as any <code>delete</code> does. The same list covers a translation whose <code>slug:</code> is changed -- in the header <code>translate</code> opens, or in a text an app sends -- on a post that is out: the address its page stood at is written into the list by that save, as a post&#39;s own old address is when it is renamed, and redirects to the new one in that language&#39;s tree. (A translated <i>page</i> has no year in its address and the list has no way to say one; there the old address is yours to keep, with <code>redirect_from</code>.) For the merge the list is written by hand, and nothing drops it: <code>translate</code> and an edit from an app carry it across, and taking the language off the post hands the list to the post itself, where each address then leads to the post.</p>
<p>Such an address is answered in two places and no others -- the site&#39;s root and that language&#39;s tree -- and only while the language is published: take the language out of <code>site.locales</code> and its old addresses answer nothing, which <code>check</code> says of each entry. <code>check</code> asks each tree about its own pages, as the build does. An old address another post&#39;s page stands at in one of them is reported with where it is taken and where it is still served; and one written into the lists of two languages leads, at the root, to the text of the language the file names first -- <code>check</code> names that too.</p>
<p><b>A post you have not translated is not hidden.</b> It stays in the other language&#39;s listing and the link on it goes to the address the post really has, so the same words never stand at two addresses. The language switcher still offers every language; one with nothing of this post in it leads to that language&#39;s front page rather than to a 404. What is promised to a search engine (<code>hreflang</code>) is narrower on purpose: only the addresses that exist.</p>
<p><b>Pictures belong to the post</b>, not to one of its languages, so they are added with <code>edit</code> and referred to from a translation. <code>translate</code> refuses an attachment rather than putting a file in one language&#39;s copy of a post whose media the other languages share.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Writing from a phone</title>
  <link>https://manual.blogsh.app/posts/2026/writing-from-a-phone/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/writing-from-a-phone/</guid>
  <pubDate>Mon, 12 Oct 2026 11:01:00 +0200</pubDate>
  <description><![CDATA[<p>The trick is that a bare filename in an image line resolves against the <code>incoming/</code> staging directory:</p>
<ol><li>Shoot a photo, upload it via any SFTP client into <code>&lt;repo&gt;/incoming/</code> (setup: <a href="/posts/2026/running-on-a-server/">Getting started</a>).</li><li>SSH into the server, <code>./blog.sh add</code>, and write <code>![caption](photo.jpg)</code> -- no path.</li><li>If the photo hasn&#39;t finished uploading yet, the CLI waits and re-checks on Enter instead of failing.</li><li>On save the photo is copied into <code>media.nosync/&lt;year&gt;/&lt;slug&gt;/</code> and its <code>incoming/</code> copy is removed -- an empty <code>incoming/</code> means nothing is pending.</li><li>Editing that post later doesn&#39;t need the photo again: a bare filename is looked for in the post&#39;s own <code>media.nosync/&lt;year&gt;/&lt;slug&gt;/</code> first and in <code>incoming/</code> only after that, so a file that&#39;s already been saved resolves without any upload (and without being copied again).</li></ol>
<p>iPhones photograph in HEIC by default, which only Safari can display. Attaching one stops the save with the exact conversion command -- or, with <code>media.convert_heic: true</code> in <code>config/site.yml</code>, the engine converts it to JPEG itself during the save (detected by content, so a HEIC named <code>.jpg</code> is caught too; AVIF, which browsers do display, is left alone). The simplest fix is on the phone itself: Settings → Camera → Formats → Most Compatible.</p>
<p><b>A photo&#39;s GPS position never reaches the site.</b> Saving strips the place of capture from the file&#39;s metadata (<code>media.strip_location</code>, on unless <code>config/site.yml</code> turns it off -- the notes there say why); the camera, the moment and the orientation stay. <code>./blog.sh doctor --strip-location</code> cleans photos saved before the engine did this -- the only thing doctor ever writes.</p>
<p><b>Video from the same phone is mentioned, not refused.</b> The same setting decides the codec, and the save says one line when it matters: that a clip is HEVC (most browsers play it, the rest show an empty player), or that it is a QuickTime <code>.mov</code> (the video inside is usually ordinary H.264, but not every browser accepts the container). Both come with the <code>ffmpeg</code> command that fixes them -- re-encoding for the codec, repacking for the container, which copies the video across untouched. A third line is about neither: a video whose index sits at the end of the file, which is where a recorder has to put it, and which makes a reader wait for the whole download before the first frame appears. The same repack moves it to the front.</p>
<p><code>media: remux_video: true</code> has the engine do that repack itself, when <code>ffmpeg</code> is on the machine -- index to the front, <code>.mov</code> to <code>.mp4</code>, the picture and the sound copied across untouched, about a second for a phone video. Every video and sound track comes across, not just the first of each, and so does the recording time; the metadata tracks an iPhone adds to a <code>.mov</code> do not, because an MP4 has nowhere to keep them. It is off by default, like the HEIC conversion, because it shells out to a tool the engine does not ship. Unlike the HEIC conversion it never refuses: no <code>ffmpeg</code>, or a repack that will not go through, and the post is saved with the file as it arrived and the sentence the author would have had anyway. A video in the wrong wrapper still plays for nearly everybody; a HEIC photo does not.</p>
<p>The post is saved either way; the only hard stop for a video is the per-file size limit, and a long 4K clip reaches that on its own -- a phone clip out of the share sheet runs about 1.3 MB a second, so the 24 MB default is roughly seventeen seconds of it. A longer one wants a smaller size chosen in the shortcut&#39;s own Encode Media step.</p>
<h2 id="handing-over-a-whole-file">Handing over a whole file</h2>
<p>Every step above is a conversation: the CLI opens an editor, waits for a photo, asks what to do with the draft. <code>./blog.sh add &lt;file&gt;</code> is the same work with the conversation removed -- it takes a markdown file with the usual header, writes the draft, and returns. It never asks anything, so it is the route for a shortcut, a script or anything else running where nobody is at the keyboard. The file is read as the editor&#39;s buffer is: a line starting with <code>//</code> is a note to yourself and does not reach the post (<code>//--more--//</code> is the one that does, and a <code>//</code> inside a code fence is the code), and neither does a <code>&lt;!-- ... --&gt;</code> block.</p>
<pre class="code-block"><code class="language-bash">./blog.sh add clanek.md</code></pre>
<p>A bare name -- one with no slash in it -- is looked for in <code>incoming/</code>, so the file can arrive by the same upload as the photos. Anything else is a path and is used as given, relative to wherever you were standing. A file that came out of <code>incoming/</code> is deleted once the post is written -- the photos already work that way, and an empty <code>incoming/</code> is what says nothing is pending. <b>A file outside </b><b><code>incoming/</code></b><b> is never touched</b>: on a Mac that is quite possibly the only copy. The rule is about where the file <i>is</i>, not how you named it, so <code>./blog.sh add &quot;$PWD/incoming/x.md&quot;</code> tidies up after itself like any other upload.</p>
<p>Where the wizard would ask, this refuses instead, and writes nothing:</p>
<ul><li>a photo that has not finished uploading -- the wizard waits and re-checks on Enter; here there is nobody to wait for, so the missing names are printed and the post is not written. Upload them and run it again.</li><li>an empty body, a file that is not text, a file this account cannot read, something that is not a file at all, an option the command does not have, or a second filename (one file, one post -- a name silently dropped is a post a looping script thinks it made).</li></ul>
<p>It stops at the draft. The file says <i>write this down</i>, not <i>put it in front of the world</i> -- publishing stays a second command:</p>
<pre class="code-block"><code class="language-bash">./blog.sh publish &lt;slug&gt; --yes</code></pre>
<p><code>--yes</code> answers the draft dialog with &quot;publish&quot; in advance. <code>--json</code>, which needs <code>--yes</code> beside it, prints the same object <code>add --json</code> does -- <code>ok</code>, <code>slug</code>, <code>path</code>, <code>state</code>, <code>scheduled</code>, <code>date</code>, <code>url</code>, <code>deploy</code>, <code>warnings</code>, the keys every action on a post answers with -- or a refusal with its reason as a code (<code>not_found</code>, <code>already_published</code>, <code>publish_refused</code>), and leaves with <b>zero</b> either way, for the reason <code>add</code> does: a phone throws away the output of a command that failed. <code>--no-announce</code> publishes the page and sends nothing to Mastodon or Bluesky; it works with or without <code>--yes</code>, and because nothing was attempted, <code>./blog.sh toot &lt;slug&gt;</code> can still send the announcement by hand afterwards. (It does not travel into a <i>scheduled</i> publish -- the cron works from the post itself and will announce it when its date arrives; the run says so if you schedule under the flag.)</p>
<p>One question <code>--yes</code> will not answer for you: a post dated outside the recent window is published but <b>not</b> announced, and the run says so. Announcing is the one step that cannot be taken back. Two more it refuses rather than guesses: <code>--yes</code> needs the slug spelled out (without one, <code>publish</code> offers the drafts to pick from, and picking belongs with the preview in front of you), and it will not choose between two posts sharing a slug in different years -- it names the years and stops.</p>
<p><code>--json</code> turns the answer into data -- one object on standard output and nothing else, so a caller can read it without parsing prose:</p>
<pre class="code-block"><code class="language-json">{
  &quot;slug&quot;: &quot;psano-v-posteli&quot;,
  &quot;path&quot;: &quot;/home/me/blog/content.nosync/posts/2026/psano-v-posteli.json&quot;,
  &quot;state&quot;: &quot;draft&quot;,
  &quot;url&quot;: &quot;https://example.com/draft/89260b63fb498e75/psano-v-posteli/&quot;,
  &quot;deploy&quot;: &quot;done&quot;,
  &quot;warnings&quot;: []
}</code></pre>
<p>Every key is always there. <code>deploy</code> is <code>done</code> when the site is already carrying the draft and <code>pending</code> when it owes an upload that the next scheduled run will finish -- the difference between &quot;open this address now&quot; and &quot;open it shortly&quot;. <code>warnings</code> collects everything the run said for itself, which on this route is the only place to hear it: a photo over the size limit, a video whose container some browsers refuse, a HEIC that was converted. <code>url</code> is empty rather than half an address when the site has no <code>base_url</code> yet, and the reason is in <code>warnings</code>. The exit code is 0 or 1, nothing else. A refusal answers in the same place and leaves with zero too, as a different object -- <code>ok</code>, <code>error</code>, <code>message</code> -- because the status answers a question the object cannot: whether an answer arrived at all. Only a flag the command does not have and a second filename fall outside this, and those end in prose on standard error.</p>
<h2 id="a-post-sent-from-the-phone-itself">A post sent from the phone itself</h2>
<p><b>This page and its shortcut are on their way out.</b> Native apps for iOS and Android are in preparation; they reach the engine over the same SSH (<a href="/posts/2026/driving-the-engine-from-a-program/#over-ssh-from-an-app">Over SSH, from an app</a>) and need neither. 1.11 removes <code>/write/</code> -- the page and everything published under that address -- the <code>SendPost</code> shortcut and the <code>publish.txt</code> and <code>drafts.txt</code> requests described below. In 1.10 everything in this section works as written -- but for the link that opens a draft (<a href="#editing-a-draft-from-the-phone">Editing a draft from the phone</a>) -- and nothing is being added to it. Two things stay, because the apps use them as the page does: the receiver itself, <code>scripts/receive.sh</code>, and the <code>receipt:</code> line of a delivery, which is what makes a delivery that arrives twice one post.</p>
<p><code>write: true</code> publishes a page at <code>/write/</code> to write the post on: a title, the text, tags, and photographs each with its description. It keeps what is typed in the browser, so closing the tab in a tunnel loses nothing -- the text in localStorage, the bytes of the pictures and videos in IndexedDB, which holds hundreds of megabytes where localStorage holds five. The page is marked <code>noindex</code> and holds no secret of its own -- what sending needs is the key, and that lives in the shortcut on the phone. Add it to the home screen and it opens as an app -- that is what the manifest among the published files is for.</p>
<p>It also carries a content policy of its own, which is the one thing it could not inherit: the page is copied onto the site as a file and never goes through the layout every other page is rendered by, so until 1.7 it had none. Now it does, and it is a short list -- scripts and styles from the blog, pictures and video only as the page&#39;s own <code>data:</code> and <code>blob:</code> bytes, one place to connect to (itself, for the receipt), the preview frame same-origin, no form action at all, and scripts never inline. A reply arriving in the address bar has nowhere to reach even if it carried something that ran.</p>
<p>The page is the same file on every site; what is this site&#39;s the build writes beside it as <code>write/site.js</code>: the short name and claim for the header, the palette from <code>colors:</code> so the page is dressed like the blog, the favicon, and every tag the blog has used, with its count of all time and of the last twelve months. The tags are offered as they are typed: with nothing typed yet, what the blog has tagged in the last year, most used first -- not the most used of all time, which on an imported archive are the places it came from; then whatever begins with or contains the letters so far, searched across every tag. So a tag is tapped rather than typed, and typed as it was before instead of the blog growing a second spelling of it.</p>
<p>The page speaks the blog&#39;s language, <code>site.lang</code>, whatever the phone is set to; the browser&#39;s is used only where there is no <code>site.js</code>, or where the blog&#39;s language is one the page has not been translated into. Above the text sits a row of marks -- bold, italic, strikethrough, code, link, heading, quote, list, numbered list, code block. A paired mark wraps what is selected, and a second tap takes it off; with nothing selected it puts the pair in and leaves the caret between. A line mark goes to the start of every line the selection touches, and comes off the same way. The link mark reads the selection, widened to whole words: an address -- <code><a href="https://…">https://…</a></code>, or a bare domain, which gets its <code>https://</code> -- becomes a link with the words left to type; words become one with the address left to type.</p>
<p>A picture does not travel as it is. The page draws it through a canvas first -- 2560 px on its long edge, JPEG at quality 0.88 -- which is what makes a post of four photographs fit under the ceiling at all, and it is why the file arrives as <code>vlak-v-chocni.jpg</code> whatever the phone called it: the name is folded to ASCII so the markdown can name it without escaping, and a second picture folding to the same name gets <code>-2</code>. A picture this browser cannot decode -- a HEIC outside Safari -- is passed through untouched under its own extension. Send the original by SFTP and use <code>add</code> if the re-encode is not what you want.</p>
<p>A video goes the same way as a picture -- the same button, the same card, the same description -- and into the text as <code>!![description] (clip.mp4)</code>, two marks where a picture has one. Nothing on the page shrinks it: it travels as it is, and the blog says on arrival what it says of any phone video (see below). What limits it is the receiver&#39;s ceiling on one delivery, <code>BLOGSH_MAX_MB</code> (24): the page carries that number in <code>site.js</code>, says under the pictures what happens to a picture, what to a video and how much the server takes, and turns the batch line red the moment the post outgrows it -- before the phone spends the upload finding out. The first tap on Send stops there and says it; a second tap sends anyway, because the page&#39;s number is the build&#39;s and the server&#39;s may have been raised since. The receiver reads the variable in its own environment, the forced command&#39;s, and the build reads it in the build&#39;s; raise it in both places or in neither.</p>
<p><i>Preview</i>, under the text, shows the post as the blog would show it -- in the blog&#39;s own stylesheets, which <code>site.js</code> names, with the pictures in their places. Near enough, not exact: the engine renders the real thing, and the draft&#39;s preview after sending is that. What it is for is seeing the shape of a post before it leaves the phone.</p>
<p>Pressing send hands the files to iOS: <b>one for each picture, and one for the text</b>. Nothing is packed for the server: it takes files, not archives. A browser that refuses to hand files over at all gets the post saved as one archive instead -- a net, so a post written on the way home is not lost to a browser that will not share it, rather than a road this page offers. <code>/write/</code> is for a phone; on a phone the files go over.</p>
<p><b>Under Chromium the text is called </b><b><code>&lt;name&gt;.md.txt</code></b><b>.</b> Chrome and Edge, on Android as at a desk, share from a page only a file whose extension is on their own list, and <code>.md</code> is not on it while <code>.txt</code> is; a <code>.md</code> is refused before any sheet opens, and <code>canShare</code> says true all the same. The page tells Chromium by <code>navigator.userAgentData</code> and names the text accordingly; the receiver puts the <code>.md</code> back, so whatever takes the files on such a phone passes them on under the names it was given and nothing else. WebKit keeps <code>.md</code>, and must: iOS reads a <code>.txt</code> as text rather than a file and hands the shortcut its first line for a name. A browser the page misjudges gets a second tap -- the refusal says so -- which sends the text as <code>.md.txt</code>. Note what the list also leaves out: <code>.zip</code>, <code>.heic</code> and <code>.mov</code>. A video from an Android phone is <code>.mp4</code> and goes; the saved archive never went anywhere but a shortcut anyway.</p>
<p><b>One shortcut, run twice, because the run that receives the files may not open an SSH connection.</b> A shortcut started from the Share Sheet runs in Shortcuts&#39; background runner, whose only screen is a banner; <i>Run Script over SSH</i> asks for a screen there -- the host prompt, the first-run privacy question -- and is refused with &quot;This action could not be run with the current user interface&quot;, before any connection is made. The action that would hand the run over to the full app, <i>Continue in Shortcuts App</i>, is no longer offered. A shortcut started from a URL, on the other hand, always runs in the app. So the same shortcut runs twice: from the Share Sheet it packs the files into one batch, writes it to a file and opens a URL that names itself; from that URL, with nothing handed to it, it reads the file back and sends it. The two runs are told apart by the one thing that differs between them, whether there is a Shortcut Input. One shortcut per blog.</p>
<p>&quot;Show in Share Sheet&quot; on, accepting Images and Files; &quot;If there&#39;s no input&quot;: <b>Continue</b> -- not Stop and Respond, because the second run has none and has to go on. Then:</p>
<ol><li><b>If</b> <i>Shortcut Input</i> <b>does not have any value</b> -- the second run. Inside it:<ol><li><b>Get File</b> <code>incoming/batch.txt</code> from the <b>Shortcuts</b> folder in iCloud Drive, without the document picker. A folder chosen in the action, not a variable; see the warning below.</li><li><b>Run Script over SSH</b> with that file as the <b>Input</b>; the script is the receiver, or the wrapper that reaches it.</li><li><b>Base64 Encode</b> the <i>Shell Script Result</i> (Line Breaks: None, if the option is offered; the page copes either way).</li><li><b>Delete Files</b> with the <i>File</i> from Get File, <i>Immediately Delete</i> on. Without the switch it asks before every post; without the action a batch already sent would wait for the next run that finds nothing to share, and go out a second time.</li><li><b>Text</b>: <code><a href="https://YOUR-BLOG-URL/write/#b=">https://YOUR-BLOG-URL/write/#b=</a></code> followed by the <i>Base64 Encoded</i> variable, with nothing between them.</li><li><b>Open URLs</b> with that text.</li><li><b>Stop and Output</b> the result, or <b>Stop This Shortcut</b> -- the second run ends here and never reaches the loop below.</li></ol></li></ol>
<p><b>End If.</b> 2. <b>Repeat with Each</b> over Shortcut Input -- the first run -- and inside it: Get <b>Name</b>,    Get <b>File Extension</b>, then a <b>Text</b> holding <code>mov mp4 m4v MOV MP4    M4V</code> and an <b>If</b> <i>Text contains File Extension</i>. Inside the If:    <b>Encode Media</b> the Repeat Item with Size <b>1280x720</b>, and <b>Set    Variable</b> <code>item</code> to <i>Encoded Media</i>; in Otherwise: <b>Set Variable</b>    <code>item</code> to <i>Repeat Item</i>. After End If: <b>Base64 Encode</b> <code>item</code>, a    <b>Text</b> of three lines -- <code>Name.File Extension</code>, <code>Base64 Encoded</code>,    <code>.</code> -- and <b>Add to Variable</b> <code>batch</code>.</p>
<p>The If is what makes a video from the phone fit: a clip from an    iPhone, 32.5 MB of HEVC, came out of Encode Media at 5 MB of H.264, in the    QuickTime container it arrived in, so the extension the batch names    is still right. A picture takes the Otherwise branch untouched. The    page cannot know the shortcut does this, so its red line for a video    measures the original; send anyway, and the answer says what    arrived. 3. After the loop: <b>Combine Text</b> <code>batch</code> with New Lines. 4. <b>Save File</b> the combined text to iCloud Drive, into the Shortcuts    folder, <i>Ask Where to Save</i> off, subpath <code>incoming/batch.txt</code>,    <i>Overwrite If File Exists</i> on -- the same file step 1 reads back. 5. <b>Open URLs</b>: <code>shortcuts://run-shortcut?name=SendPost</code>, where    <code>SendPost</code> is the shortcut&#39;s own name, spelled as the library spells    it. Rename the shortcut and this line has to change with it; a name    without spaces or punctuation saves percent-encoding it.</p>
<figure><img src="https://manual.blogsh.app/posts/2026/writing-from-a-phone/01.png" width="1000" height="2142" alt="The shortcut as the Shortcuts app shows it, with placeholders where the server&#39;s details go: the If with Get File, Run Script over SSH, Base64 Encode, Delete Files, the Text with #b=, Open URLs and Stop and Output; then the Repeat with Each loop with the If around Encode Media, Combine Text, Save File, and the Open URLs that names the shortcut itself" loading="lazy" decoding="async"></figure>
<p>It can be imported instead of built: <a href="https://github.com/DanielSnor/blog.sh/blob/main/docs/shortcuts/SendPost.shortcut">SendPost</a>, signed so that anyone may import it. It arrives called <code>SendPost</code>, and the last <i>Open URLs</i> opens it by that name; rename it and change the name there to match. Then fill in what is yours. In <i>Run Script over SSH</i>: the machine you log into (a host name or an IP address), its SSH port, the user, and the path to <code>scripts/receive.sh</code> or the wrapper that reaches it. In the <i>Text</i>: <code>YOUR-BLOG-URL</code> is the address the site is served at, <code>base_url</code> in <code>config/site.yml</code>. The SSH action carries no key; pick or generate one there and put its public half on the server, as the key line below shows.</p>
<p>⚠️ <b>Pasting actions between shortcuts rewires them.</b> Shortcuts reconnects a pasted action&#39;s inputs to whatever stands above the place it lands. A Get File copied in this way came back reading from a <i>Text</i>, then from <i>If Result</i>, then from <i>Shortcut Input</i> -- which is empty in the second run, so it read nothing, the SSH action sent nothing, and the server answered <code>empty_input</code> for a batch that was whole. After any paste or move, check that Get File still reads <i>from Shortcuts</i>, and that the SSH <i>Input</i> and <i>Delete Files</i> both name the <i>File</i> it produces.</p>
<p><b>It runs on a Mac as well.</b> The Shortcuts app there has the same shortcut through iCloud, Safari&#39;s Share Sheet offers it, and the terminal runs it: <code>shortcuts run SendPost</code> with a <code>batch.txt</code> in the Shortcuts folder sends that batch and prints the address it would have opened -- which is how the sending half is tested without a phone. <code>/write/</code> itself is still made for a phone.</p>
<p>Base64, not URL Encode, and not for taste: Shortcuts reads a reply that is JSON as a Dictionary and then refuses to hand a Dictionary to URL Encode -- &quot;couldn&#39;t convert from Dictionary to Text&quot; -- on exactly the replies that matter, the refusals. Base64 Encode takes anything. (The page also still reads a percent-encoded reply after <code>#r=</code>.)</p>
<p>Base64 Encode, the Text and Open URLs carry the answer back to the page. It opens with the reply after <code>#b=</code> -- a fragment, so it never leaves the browser -- and says what the server did: the pictures it kept, the draft&#39;s preview address or the post&#39;s public one, the command that publishes a draft from a keyboard, and a refusal in the reader&#39;s language where the page knows the code, in the server&#39;s own words where it does not. A post the server took is cleared from the device; a refusal keeps everything, so it can be mended and sent again.</p>
<p><b>And the page asks, as well as waiting.</b> That road back has one break in it that nothing on this end can mend: a page kept on a phone&#39;s home screen runs with storage of its own, so a reply that arrives as a URL opens in the browser, where the draft it is about does not exist -- and the draft stays on the home-screen copy, looking unsent. So the page picks a name for its answer before it sends anything (<code>receipt:</code>, sixteen hexadecimal characters, written into the markdown), and the build leaves a small JSON file at <code>/write/r/&lt;receipt&gt;.json</code> saying 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, a player that was not found. The page draws those the way it draws the answer that comes back through the address bar, because the phone is the one place with no terminal to read them in. Only what was said about the POST: the file is served to anyone who has the sixteen characters, so what the run says about the SITE afterwards (a missing <code>base_url</code>, whatever the rebuild warns about) stays out of it. The page asks for it every three seconds for fifteen minutes, and says so if it never comes; a page reopened later asks once more, and shows the answer or says it did not come. Whichever answer arrives first is the one that is shown.</p>
<p>The file is written by the BUILD, which is what keeps it true: publish the post and the next build says published and gives the public address; delete the post and nothing generates the file, so the sweep takes it away. It exists only on a site that serves the page, and only for a post that asked for one.</p>
<p><b>Publishing from the phone.</b> The answer for a draft carries a Publish button, and it sends one file called <code>publish.txt</code> holding the slug -- down the same connection, through the same shortcut. The receiver knows that shape: exactly one file, called that, and it runs <code>publish &lt;slug&gt; --yes --json</code> rather than storing anything. The slug is checked as hard as a filename is, because it becomes an argument to a command: a leading dash is a flag, and anything outside a slug&#39;s own alphabet is refused here rather than explained by whatever it hits. The page then asks the same receipt again until it says published.</p>
<h2 id="editing-a-draft-from-the-phone">Editing a draft from the phone</h2>
<p>This is what an app does: it asks the blog for its drafts (<code>drafts --json</code>) and sends the text back with <code>edits: &lt;slug&gt;</code> and <code>base: &lt;digest&gt;</code> in its header -- see <a href="/posts/2026/driving-the-engine-from-a-program/#over-ssh-from-an-app">Over SSH, from an app</a>, and the <a href="/posts/2026/writing-and-publishing/">header keys</a> for what such a save keeps and what it refuses.</p>
<p>The page was given a link for it too, <i>Open a draft from the blog</i>, which sends one file, <code>drafts.txt</code>, through the shortcut and waits for the list of drafts in the address. Tried on a tablet it loaded nothing, and it is not being repaired: the page is frozen and leaves in 1.11.</p>
<p><b>A delivery that arrives twice is one post.</b> The receipt is minted once per send, immediately before the files it names are built, so two deliveries carrying the same one are the same send arriving twice -- which is what a connection that breaks after the bytes have gone but before the answer comes back produces, and both the shortcut and the Termux script retry. The second delivery updates the post the first one wrote instead of founding another beside it -- and that holds when the retry arrives while the first is still being written, because writes carrying a receipt take turns. Once the post is <b>published</b>, a late retry stops updating anything at all: it is answered with the published post, at its public address, and the file is left exactly as it stands. The copy the phone is still holding was made before publishing happened and cannot know about it, so letting it write would put the post back to draft -- with a new address, a new date and the announcement pointing at nothing. Pressing send again is not that: the page mints a fresh receipt each time, so a second send is a second post. A delivery carrying no receipt at all -- anything hand-rolled -- has nothing to be recognised by, and a repeat of one is two posts as it always was.</p>
<p>One connection carries the post however many photographs are in it, which is the point: connections are the scarce thing, not bytes. The first run in the app asks whether the shortcut may send its items to the host -- answer Always Allow; that is the very question the background runner could not put on screen.</p>
<p>The page that sent the files sees the share end in an abort -- that is how the hand-off to the app looks from a web page, not a failure -- so it says the files have left. The answer arrives when the second run opens the page again with it.</p>
<p>The order inside the batch matters, because the markdown arriving is what makes the post and everything its text names has to be on the server by then. The app puts the markdown last for exactly this reason. Get it wrong and nothing breaks -- the engine answers <code>missing_images</code> and writes nothing, and sending the text again once the pictures are up is all it takes.</p>
<p><b>Draft or public is decided when the post is sent.</b> The page has a switch, Draft or Publish now; Draft is the default and writes nothing. Publish now writes <code>publish: yes</code> into the front matter, and <code>add</code> then takes the road <code>publish &lt;slug&gt; --yes</code> takes at a desk: the date is settled, the announcement goes out to the networks the site has configured, the site is rebuilt and deployed, and the answer carries the public address rather than a draft one. There is no third state: a post that is public is announced, on a phone as at a desk. The key is only read by <code>add &lt;file&gt;</code>; the wizard never publishes directly.</p>
<p><code>add &lt;file&gt;</code> wants the file to be on the server already. <code>receive.sh</code> is how it gets there: <b>the whole post down one connection</b>. Each file is a name on its own line, its base64 after it, and a line holding a single <code>.</code> to say that was all of it -- then the next name.</p>
<pre class="code-block"><code class="language-bash">{ for f in photo.jpg post.md; do
    printf &#39;%s\n&#39; &quot;$f&quot;; base64 &lt; &quot;$f&quot;; printf &#39;.\n&#39;
  done
} | ssh blog ./scripts/receive.sh</code></pre>
<p><b>One connection, not one per file.</b> It was one per file once, which is simpler and does not work: a server worth running drops new SSH connections that arrive in a rush, and a common setting allows about four a minute. A post with three photographs sat exactly on that line and one with nine had no chance -- the sender saw &quot;could not connect to the SSH server&quot; and the pictures that never arrived were missed by nobody, since each connection answered for itself alone.</p>
<p>Every name in the delivery is read before a single byte is written, so a batch refused on its fifth name leaves nothing behind from the first four.</p>
<p><b>The closing dot is not decoration.</b> A connection that drops halfway ends the same way a finished one does -- the receiver reads to the end of the stream either way -- so half a photograph arrived, decoded into half a picture, and was answered with <code>ok</code>. Without the dot the transfer is refused as <code>truncated</code> and nothing is stored.</p>
<p>A shortcut on a phone sends a whole post that way -- every picture, then the markdown, down one connection -- and <b>the markdown last</b>, because the markdown arriving is what makes the post. Everything its text names is already staged under the name it uses, so there is no other signal to send and none is needed. The answer to a picture is <code>{&quot;ok&quot;:true,&quot;stored&quot;:&quot;photo.jpg&quot;}</code>; the answer to the markdown is the same JSON <code>add --json</code> gives.</p>
<p>⚠️ <b>The shortcut has to SHOW the answer, or every refusal looks like it worked.</b> A refusal leaves with zero -- deliberately, because iOS Shortcuts discards the output of a remote command that failed, and the reason is the whole point of the answer. The cost is that Shortcuts then reports a tick for a refusal exactly as it does for a post. End the shortcut by opening the page with the answer, as the second run above does, or with a <i>Show Content</i> of the SSH output. The reply is one JSON object per file, so read it whole: a picture answers <code>&quot;ok&quot;:true</code> and the name it was <code>stored</code> under, a refusal answers <code>&quot;ok&quot;:false</code> and names the reason, and the post that was written answers with its <code>slug</code> (and, since 1.10, <code>&quot;ok&quot;:true</code> like every other answer). Without that, a shortcut whose Input field is empty connects, waits out the thirty-second deadline, is refused for <code>empty_input</code>, and shows a tick -- which is a slow, silent way to learn nothing.</p>
<p><b>The exit code answers a different question from the JSON.</b> Zero means an answer arrived -- read the object, which says <code>&quot;ok&quot;</code> and, when that is false, names the reason. Non-zero means there is no object to read at all: the script was killed, or the shell never got to run it. Everything the receiver can put into words leaves with zero, and that includes the refusals about the installation rather than the delivery -- no <code>incoming/</code>, no engine, a ceiling that is not a number. Which kind of trouble it is, the error code says. (iOS Shortcuts discards the output of a remote command that failed, so a refusal that exited non-zero reached a phone as a bare status with its reason gone -- exactly when the reason was the point, and those refusals are the ones a new install meets on its very first delivery.)</p>
<p>The body has a deadline of its own, <code>BLOGSH_BODY_SECONDS</code> (600): a delivery that has not finished by then is dropped and, if the sender is still listening, told <code>timeout</code>; it is set where <code>BLOGSH_MAX_MB</code> is, below. Two files under one name in one delivery are refused (<code>bad_name</code>) rather than one silently replacing the other, every body is decoded before any file is written, so a refusal on the third picture leaves nothing of the first two behind, and a blank line after a complete delivery is not held against it. When the engine itself does not answer in JSON -- no <code>env.sh</code>, no ruby, a configuration that will not parse -- the receiver answers for it with <code>engine_failed</code> and the words the engine printed, and every delivery ends with status 0 once an answer has been given -- a ceiling that is not a number (<code>bad_limit</code>) included.</p>
<p>Deliveries are taken in one at a time. From the first file of a delivery landing in <code>incoming/</code> to the engine having taken them out again, a second delivery waits: <code>incoming/</code> is one folder, a picture is known there by the name it was sent under, and two phones -- or one app sending two posts -- both call their first picture <code>01.jpg</code>. The one that waits does so for up to <code>BLOGSH_TURN_SECONDS</code> (300, set where the other two are) and is then told <code>busy</code>, with nothing of it stored; send it again. A delivery that died mid-way does not hold the next one up.</p>
<p>A delivery that goes wrong halfway can simply be repeated. Pictures wait in <code>incoming/</code> until a text names them, so a refused post leaves them where they are and sending it again finds them -- nothing has to be uploaded twice.</p>
<p><b>Nothing new listens on the network.</b> It travels over the SSH the server already has, and the key it travels on wants a forced command:</p>
<p>restrict,command=&quot;/path/to/blog/scripts/receive.sh&quot; ssh-rsa AAAA... phone</p>
<p>The key has to be RSA. Shortcuts&#39; <i>Run Script over SSH</i> does not speak ed25519 and says nothing when it meets one -- the run simply fails.</p>
<p><b>What a stranger chooses is a filename and some bytes</b>, so that is what is checked. A name carrying a path or a backslash, beginning with a dot, empty, longer than a filesystem will take, or holding a control character is refused (<code>bad_name</code>) and nothing is written. What arrives is bounded before it lands rather than weighed afterwards: <code>BLOGSH_MAX_MB</code> (24) is a ceiling on reading, so a sender costs that much and not whatever they felt like sending. The number is measured on the encoded stream, not on the files -- base64 is a third larger -- so a 24 MB ceiling takes about eighteen megabytes of photographs; the page&#39;s legend says the same, and the red line it draws counts the encoded size. A delivery that overshoots is read to its end into nothing -- up to four times the ceiling -- and then refused, so the sender finishes and hears <code>too_large</code>; refused the moment the ceiling was reached, a phone with megabytes still to send sat on &quot;running&quot; with no timeout to save it, and the answer never arrived. Beyond that bound the channel closes, and a hostile sender costs reading, never storage. And the post itself is handed to the engine as <b>untrusted</b>, so a picture may be named only by a bare filename -- <code>![](/etc/passwd)</code> is refused (<code>bad_reference</code>), because otherwise it would read that file into the post and publish it.</p>
<p><b>Raising the ceiling is two settings, not one.</b> The receiver reads <code>BLOGSH_MAX_MB</code> from its own environment -- the one the forced command runs in: put it in the key&#39;s line, <code>command=&quot;BLOGSH_MAX_MB=64 /path/to/blog/scripts/receive.sh&quot;</code>, or, where the command is a wrapper that enters a container, in that wrapper (<code>docker exec -e BLOGSH_MAX_MB=64 …</code>). The page at <code>/write/</code> never asks the server; it shows the number the BUILD saw in <code>BLOGSH_MAX_MB</code> when it wrote <code>site.js</code> -- so set the same value where the build runs (<code>env.sh</code>) and rebuild, or the page will warn at 24 while the server takes 64, or say nothing while the server refuses. Unset on both sides, both say 24.</p>
<p>There is deliberately no archive. An earlier design took a ZIP and unpacked it here; three adversarial audits found blocking faults in three successive rewrites of it, and nearly all of them lived in the unpacking -- entries that were symlinks, names that collapsed onto each other, a bomb that wrote gigabytes from megabytes, a half-failed extraction that published a truncated photograph and reported success. None of that can be expressed when there is nothing to unpack.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Driving the engine from a program</title>
  <link>https://manual.blogsh.app/posts/2026/driving-the-engine-from-a-program/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/driving-the-engine-from-a-program/</guid>
  <pubDate>Mon, 12 Oct 2026 11:00:00 +0200</pubDate>
  <description><![CDATA[<p>Every screen a person reads before acting, and every action those screens offer, has a <code>--json</code> form: one object on standard output, nothing else on it, every key always present -- a consumer that has to test for a missing key is a consumer writing its own parser. A refusal is an object too, <code>{&quot;ok&quot;: false, &quot;error&quot;: &quot;&lt;code&gt;&quot;, &quot;message&quot;: &quot;&lt;a sentence&gt;&quot;}</code>, with a <b>zero</b> exit: the status answers whether an answer arrived at all, the object says what it was. (The engine falling over is the one thing that still leaves with 1 and prose on stderr.) This is the contract <code>add --json</code> and <code>publish --yes --json</code> have kept since the phone first needed them; the rest of the engine keeps it now.</p>
<p>Three rules follow from &quot;nobody is at the keyboard&quot;:</p>
<ul><li><b>A question the dialog asks is a flag.</b> The slug typed back to confirm a delete is <code>--yes</code>; the date the schedule dialog asks for is <code>--at</code>; &quot;shift the rest of the queue forward?&quot; is <code>--compact</code>; &quot;rebuild and deploy now?&quot; is <code>--rebuild</code>; &quot;publish it though one of the site&#39;s languages has no text for it yet?&quot; is <code>--allow-partial</code>. Without the flag the answer is no -- a program that did not say it did not mean it. (On a site of more than one language that last one decides whether anything can be scheduled or published at all before it is translated: without it both answer <code>partial_translation</code> -- unless the site itself says <code>publishing.allow_partial: true</code>, which <code>props --json</code> shows as <code>languages.allow_partial</code>, and then neither does.)</li><li><b>Those flags are for </b><b><code>--json</code></b><b> only.</b> <code>delete &lt;slug&gt; --yes</code> without <code>--json</code> is refused: at the terminal the dialog is there to ask, and a flag that silently skips a confirmation is how a shell history deletes a post.</li><li><b>A post is named by its slug, and never guessed.</b> The same slug in two years is refused as <code>ambiguous_slug</code> where the terminal would show both and ask; <code>list --json</code> carries the year so a program can tell them apart, and will be able to name one. A slug no post has is <code>not_found</code>.</li></ul>
<div class="table-wrap"><table><thead><tr><th>command</th><th>what it answers</th><th>refusals</th></tr></thead><tbody><tr><td><code>version --json</code></td><td><code>engine</code>, <code>site</code>: name, claim, url, lang, locales, accent (<code>light</code>, <code>dark</code>: the palette&#39;s accent as colors.css resolves it), palette (<code>light</code>, <code>dark</code>, each <code>bg</code>, <code>text</code>, <code>meta_text</code>, <code>border</code>, resolved the same way) -- the identity block as data; answers on a broken config with what it can</td><td>--</td></tr><tr><td><code>list [--type=&lt;type&gt;] [--tag=&lt;tag&gt;] [--search=&lt;words&gt;] [--drafts] --json</code></td><td><code>posts</code> (slug, year, date, title, type, tags, state, scheduled, series, pinned, match), <code>count</code>, <code>drafts</code>, <code>search</code>; <code>--search=&quot;words&quot;</code> is the search <code>browse</code> runs behind [/] -- the same query language, over the whole text -- and <code>match</code> is the line that says why a row is there (null when the hit is in the title or a tag); <code>search</code> says the query back, null when there was none. A filter&#39;s value is written after <code>=</code> or as the next word (<code>--tag=cats</code>, <code>--tag cats</code>); a filter with nothing after it (<code>--tag</code>, or <code>--tag=</code>), or a word the command has no use for, is an abort -- but an empty <code>--search=</code> is a query, which matches nothing</td><td>--</td></tr><tr><td><code>props &lt;slug&gt; --json</code></td><td>the properties screen as data, with <code>actions</code>: the keys it would offer this post; <code>network</code> says which one [t] announces on, <code>slot</code> the time the schedule dialog would offer a plain draft; <code>type_set</code>, <code>hero</code> and <code>toc</code> are the [e] rows in the words <code>--set</code> takes back, and so is <code>series_part</code> -- a string or null, though the post&#39;s file holds a number; <code>languages</code> names the site&#39;s own (<code>own</code>) and says of each other one (<code>others</code>) <code>none</code>, <code>started</code> or <code>written</code> -- the three states <code>check --languages --json</code> calls <code>missing</code>, <code>title_only</code> and <code>written</code>; <code>languages.allow_partial</code> is true on a site that lets a post out without the words of every language (<code>publishing.allow_partial</code>), where <code>publish</code> and <code>schedule</code> need no <code>--allow-partial</code></td><td><code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>props &lt;slug&gt; --set key=value ... --json</code></td><td>the screen after the write (<code>deploy</code>, <code>warnings</code> added); keys: series, series_part, tags, type, unlisted, hero, toc, pinned -- the words the screen shows (yes/no, default for hero and toc, <code>-</code> to clear)</td><td><code>bad_property</code>, <code>busy</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>props &lt;slug&gt; --drop-address &lt;address&gt; [--kind &lt;kind&gt;] --json</code></td><td>the screen after the drop. The address is one <code>addresses</code> lists -- each with its <code>kind</code>: <code>former_slugs</code>, <code>redirect_from</code>, or <code>translations.&lt;lang&gt;.former_slugs</code> for an old address of the post&#39;s text in another language. An address two of the lists hold is taken out of the one <code>--kind</code> names</td><td><code>address_unknown</code>, <code>address_ambiguous</code> (in two lists, and no <code>--kind</code>), <code>kind_unknown</code> (no such list holds it), <code>kind_needs_drop</code>, <code>kind_once</code>, <code>busy</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>props &lt;slug&gt; --rename &lt;slug&gt; --yes [--rebuild] --json</code></td><td>the screen of the renamed post; a draft&#39;s preview is rebuilt, a published post waits for <code>--rebuild</code></td><td><code>busy</code>, <code>rename_unusable</code>, <code>rename_too_long</code>, <code>rename_same</code>, <code>rename_taken</code>, <code>rename_unreadable</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>props &lt;slug&gt; --versions --json</code></td><td><code>versions</code>: name, date, label, newest first</td><td><code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>props &lt;slug&gt; --restore-version &lt;name&gt; --yes --json</code></td><td>the screen after the restore, the preview rebuilt</td><td><code>busy</code>, <code>version_unknown</code>, <code>version_unreadable</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>queue --json</code></td><td><code>queue</code>: position, date, slug, year, title, overdue; <code>scheduler</code>: <code>last_run</code>, when something last ran the queue, null where nothing ever has</td><td>--</td></tr><tr><td><code>queue --up &lt;slug&gt; --json</code>, <code>--down &lt;slug&gt;</code>, <code>--move &lt;slug&gt; --to &lt;n&gt;</code></td><td>the queue afterwards, <code>warnings</code>; nothing is rebuilt -- the screen rebuilds once on the way out, a program calls <code>rebuild --json</code> when it is done</td><td><code>not_scheduled</code>, <code>ambiguous_slug</code>, <code>not_moved</code> (first, last, or its time has passed), <code>bad_position</code>, <code>overdue</code>, <code>busy</code></td></tr><tr><td><code>toot &lt;slug&gt; [--force] --json</code>, <code>bluesky &lt;slug&gt; [--force] --json</code></td><td><code>url</code>, <code>recovered</code> (Bluesky found the announcement on the account instead of sending one)</td><td><code>busy</code>, <code>wrong_network</code>, <code>no_network</code>, <code>still_draft</code>, <code>already_announced</code>, <code>unlisted</code>, <code>outside_window</code> (unless <code>--force</code>), <code>failed</code>, <code>not_sent</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>drafts --json [&lt;slug&gt;]</code></td><td>the drafts with their text, as the phone opens them; every entry says <code>omitted</code> -- true where its text was left out of a long answer and has to be asked for by name</td><td><code>not_found</code></td></tr><tr><td><code>translate &lt;slug&gt; --lang &lt;code&gt; --json</code></td><td>the post&#39;s text in that language as the editor opens it (empty when there is none yet), the original beside it, the post&#39;s media by name, <code>preview</code> (the address of the post&#39;s own page -- not of its page in that language), <code>base:</code>, and <code>editable</code> with <code>problem</code> as <code>edit --json</code> says them; the words come back through <code>add</code> of a file saying <code>edits:</code>, <code>base:</code> and <code>lang:</code> -- a title and a body both empty take the language off the post, and what the text could not carry is refused (<code>content_lost</code>)</td><td><code>no_locales</code>, <code>own_language</code>, <code>unknown_language</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>edit &lt;slug&gt; --json</code></td><td>one post, any state, with its text as the editor opens it, its media, its address and the digest <code>base:</code> hands back; the text comes back through <code>add</code> of a file saying <code>edits:</code> -- for a published post <code>base:</code> is required, and the save rebuilds and deploys</td><td><code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>schedule &lt;slug&gt; --at &lt;time&gt; [--allow-partial] --json</code></td><td>the post, <code>position</code> in the queue, <code>compacted</code> (0). The time is <code>YYYY-MM-DD HH:MM</code> -- a <code>T</code> for the space, seconds and a zone (<code>...Z</code>, <code>+02:00</code>) if wanted -- or <code>HH:MM</code> alone for today; it is written down, and the post filed, in the site&#39;s own zone. Said any other way, or naming a day the calendar does not have, it is <code>bad_date</code></td><td><code>schedule_needs_at</code>, <code>bad_date</code> (a day without a time too), <code>not_future</code>, <code>already_published</code>, <code>partial_translation</code> (unless <code>--allow-partial</code>), <code>busy</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>schedule &lt;slug&gt; --cancel [--compact] --json</code></td><td>the post back among the drafts, <code>compacted</code>: how many moved forward</td><td><code>not_scheduled</code>, <code>already_published</code>, <code>busy</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>publish &lt;slug&gt; --yes [--no-announce] [--allow-partial] [--compact] --json</code></td><td>the published post (<code>add --json</code>&#39;s shape), <code>compacted</code>; <code>--no-announce</code> puts it on the site and tells no network</td><td><code>busy</code>, <code>already_published</code>, <code>partial_translation</code> (unless <code>--allow-partial</code>), <code>publish_refused</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>unpublish &lt;slug&gt; --yes --json</code></td><td>the post as a draft, <code>announcement_kept</code>: addresses that could not be deleted</td><td><code>busy</code>, <code>already_draft</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>delete &lt;slug&gt; --yes [--rebuild] --json</code></td><td><code>trash</code>: where it went, <code>announcement_kept</code>, <code>deploy</code></td><td><code>busy</code>, <code>not_found</code>, <code>ambiguous_slug</code></td></tr><tr><td><code>restore --json</code></td><td><code>trash</code>: what is in it (slug, year, date, title, type, tags, state, media_only)</td><td>--</td></tr><tr><td><code>restore &lt;slug&gt; [--rebuild] --json</code></td><td>the post back in place</td><td><code>busy</code>, <code>not_found</code>, <code>ambiguous_slug</code>, <code>media_only</code></td></tr><tr><td><code>rebuild [--full] [--force] --json</code></td><td><code>deploy: done</code>, <code>warnings</code></td><td><code>busy</code>, <code>rebuild_failed</code></td></tr><tr><td><code>empty trash|versions [--yes] --json</code></td><td><code>count</code>, <code>bytes</code>, <code>size</code>, <code>emptied</code> -- without <code>--yes</code> only the count, which is the question the terminal asks</td><td><code>empty_what</code></td></tr><tr><td><code>check --json</code>, <code>stats --json</code></td><td>the findings, the figures -- see their own sections</td><td>--</td></tr><tr><td><code>on-this-day [--date &lt;day&gt;] --json</code></td><td>the day -- see its own section; <code>--date</code> is <code>MM-DD</code>, or <code>YYYY-MM-DD</code> to count the ages from another year</td><td><code>bad_date</code></td></tr><tr><td><code>check --languages --json</code></td><td>the table of languages alone: <code>languages</code> (the site&#39;s own first) and <code>rows</code> (slug, year, title, <code>cells</code>: per language <code>written</code>, <code>title_only</code> or <code>missing</code>); empty on a site that publishes one language. Over a <code>config/site.yml</code> that cannot be read it refuses and exits 1</td><td><code>config_missing</code>, <code>config_empty</code>, <code>config_syntax</code>, <code>config_unreadable</code></td></tr><tr><td><code>doctor --json</code></td><td>the installation&#39;s diagnosis: <code>errors</code>, <code>warnings</code>, <code>oks</code> and <code>findings</code> (<code>level</code>: error, warn or ok; <code>kind</code>: the check it came from, such as <code>trash</code>, <code>scheduler</code>, <code>deploy</code> or <code>banner</code>; <code>text</code>; <code>fix</code>, null where there is no advice), problems first; no <code>ok</code> key, and the status is 1 when there is an error</td><td>refused with <code>--strip-location</code> (status 2, in prose)</td></tr></tbody></table></div>
<p><code>deploy</code> in an answer is <code>done</code> when the site was rebuilt and deployed, <code>pending</code> when the rebuild was attempted and the next scheduled run owes it (the <code>.deploy-pending</code> marker), and <code>skipped</code> when the action does not rebuild unless asked and <code>--rebuild</code> was not given. The actions that rebuild without asking at the terminal -- <code>publish</code>, <code>unpublish</code> -- do so here too; the ones the terminal asks about -- <code>delete</code>, <code>restore</code> -- wait for the flag, so a program making three changes can rebuild once.</p>
<p>What a refusal&#39;s <code>message</code> says is the sentence the terminal would have printed, in the site&#39;s language; the <code>error</code> code is the same in every language and is what a program should switch on. A new code may appear in a later version; an existing one does not change meaning.</p>
<p>A command that writes a post -- <code>props --set</code>, <code>--drop-address</code>, <code>--rename</code> and <code>--restore-version</code>, <code>schedule</code>, <code>publish</code>, <code>unpublish</code>, <code>delete</code>, <code>restore</code>, <code>toot</code>, <code>bluesky</code>, a move in the queue -- answers <code>busy</code> while another run holds the archive: a build, the scheduler, another of these. Nothing was written, and the same request a moment later is the way through. (A text sent as a file is not among them: it says which version it edited and is refused as <code>changed</code>.)</p>
<p>An action on a post answers with the same nine keys whichever action it was -- <code>ok</code>, <code>slug</code>, <code>path</code>, <code>state</code>, <code>scheduled</code>, <code>date</code> (null for a plain draft), <code>url</code>, <code>deploy</code>, <code>warnings</code> -- and with what its row of the table adds. <code>add</code> of a file through a delivery answers that way too, for a new post, an edit and a translation alike. Two answers are about something other than the post and have a shape of their own: <code>delete</code> says where the post went (<code>trash</code>, <code>announcement_kept</code>, <code>deploy</code>, <code>warnings</code>), and a move in the queue answers with the queue.</p>
<p>One thing about types, for a consumer written in a language that has them: a post&#39;s <code>year</code> is a string wherever a post is named -- it is the folder the post is kept in, and what tells two posts of one slug apart -- and a number only in <code>on-this-day</code>, which counts with it.</p>
<h2 id="over-ssh-from-an-app">Over SSH, from an app</h2>
<p>A program on another machine -- the blog.sh app on a phone, a script -- reaches the engine over the SSH the server already has, on a key of its own with a forced command, the way the phone&#39;s shortcut does:</p>
<p>restrict,command=&quot;/path/to/blog/scripts/remote.sh&quot; ssh-ed25519 AAAA... app</p>
<p>A request may also say which language its reader reads, <code>{&quot;args&quot;: [&quot;doctor&quot;], &quot;lang&quot;: &quot;cs&quot;}</code>. A blog speaks its own language, and an app in Czech showing an English blog&#39;s diagnosis would show English; with <code>lang</code>, <code>check</code> and <code>doctor</code> -- the two answers that are sentences for a person, and that build nothing -- are said in that language, if the engine ships it, and so is what <code>pair</code> itself refuses (<code>device_unknown</code>, <code>busy</code>). What the door refuses before the engine is run it says in English, as it always has. Every other command answers in the blog&#39;s language as before, because a command that may rebuild the site must not be handed another one. A language the engine does not have is not an error: the answer is then in the blog&#39;s own. The code is the bare one a locale file is named by, in lower case -- <code>cs</code>; what a phone calls its language (<code>cs-CZ</code>, <code>cs_CZ</code>, <code>CS</code>) is cut down to that by the sender, or it counts as a language the engine does not have.</p>
<p><code>scripts/remote.sh</code> allows three words as the SSH command and nothing else. <code><b>run</b></code> runs one engine command: its argv arrives on standard input as one line of JSON, <code>{&quot;args&quot;: [&quot;props&quot;, &quot;venku&quot;, &quot;--set&quot;, &quot;tags=louka, les&quot;]}</code> and a newline -- the line ends the request, so a sender that cannot close its side of the stream is answered all the same -- word by word, so nothing is ever parsed as shell and a value with spaces or diacritics is one word on both ends. Before the engine sees it, <code>scripts/remote.rb</code> checks every word: the command has to be one a program may run (<code>version</code>, <code>list</code>, <code>drafts</code>, <code>edit</code>, <code>translate</code>, <code>props</code>, <code>queue</code>, <code>schedule</code>, <code>publish</code>, <code>unpublish</code>, <code>delete</code>, <code>restore</code>, <code>rebuild</code>, <code>empty</code>, <code>toot</code>, <code>bluesky</code>, <code>stats</code>, <code>on-this-day</code>, <code>check</code>, <code>doctor</code>, and <code>pair</code> for its list and the caller&#39;s own way out), each flag one that command has in its <code>--json</code> form, every positional word a slug, and no word may hold a control character or be longer than 4000 bytes. A slug here is a name as a file may have it and a reader would read it -- letters and digits of any alphabet, <code>_</code>, <code>.</code> and <code>-</code>, not starting with <code>.</code> or <code>-</code> -- which is wider than what the engine itself makes (lowercase, digits, hyphens) because an import keeps the names the old system gave: a post <code>list</code> hands out can be asked for by that name. <code>--json</code> is added when it is missing, so the answer is always an object -- and with it <code>edit</code> and <code>translate</code> open no editor: they hand the text out, and the changed text comes back as a file through <code>deliver</code>. What is not on the list is what asks a question, writes outside the archive or reaches a network on somebody else&#39;s account: <code>add</code> (a post arrives through <code>receive</code>), <code>export</code>, <code>preview</code>, <code>browse</code>, <code>check --repair</code>, <code>doctor --online</code> and <code>--strip-location</code>, the wizard -- and of <code>pair</code> everything but <code>--list</code> and <code>--revoke</code> with nothing after it: showing a code opens a way in, and a device named after <code>--revoke</code> is somebody else&#39;s (see &quot;Letting an app in by a code&quot;). <code><b>receive</b></code> is the delivery <code>scripts/receive.sh</code> takes -- pictures and a markdown, or a <code>publish.txt</code> or <code>drafts.txt</code> request -- for the same key. <code><b>deliver</b></code> is the same delivery for a sender that cannot close its side of the stream: it ends with a line saying <code>end</code> instead, which <code>scripts/remote.rb</code> reads up to and hands to <code>receive.sh</code> whole, so every check the receiver makes is made; a file may not be called <code>end</code>, and a delivery over twice the receiver&#39;s ceiling is dropped on the wire (<code>too_large</code>) before the receiver measures it exactly. <code>version --json</code> says the ceiling (<code>max_mb</code>), so a sender can weigh a delivery first.</p>
<p>Every answer to <code>run</code> is one object and the status is 0, for the reason the receiver gives. A delivery -- <code>receive</code> or <code>deliver</code> -- is answered as the receiver answers, with an object for each file, one after another: a line for every picture stored or refused, then each post that was written, printed over several lines. That is neither one JSON document nor a line apiece; a reader takes it object by object, by its braces.</p>
<p>A request that is not JSON, holds a word the whitelist refuses or is over 64 kB is refused as one (<code>bad_json</code>, <code>bad_args</code>, <code>unknown_command</code>, <code>too_large</code>), one that never arrives as <code>empty_input</code>, a connection that says none of the three words as <code>no_command</code>, and the engine answering in prose -- no <code>env.sh</code>, a configuration that will not parse -- is wrapped as <code>engine_failed</code> with its words: the end of what it said, or, where it died of an exception, the line that names the error -- and how it left, its status or the signal that ended it (a build killed for memory reads &quot;ended by signal 9 (KILL)&quot;). Two clocks answer <code>timeout</code>: the request&#39;s one line has <code>BLOGSH_LINE_SECONDS</code> (30) to end once it has begun, and an engine still running after <code>BLOGSH_RUN_SECONDS</code> (1800) is stopped with whatever it had started -- asked to end first, then ended, the whole of what it had run with it. A delivery through <code>deliver</code> is held to the same: the whole of it has the receiver&#39;s <code>BLOGSH_BODY_SECONDS</code> (600) from its first byte, however it trickles and wherever in a line it stops; it is measured against the ceiling as it comes, not line by line; and the receiver it is handed to has <code>BLOGSH_RUN_SECONDS</code> like any engine. Each of the three is a whole number of seconds, of eight digits at most, with spaces around it ignored; anything else -- a word, a zero, <code>30m</code> -- is refused as <code>bad_limit</code>, naming the variable, rather than read as no time at all. An engine that a signal ended -- a build killed for memory on a small server -- is <code>engine_failed</code> naming the signal, to a request and to a delivery alike, and an answer is always valid UTF-8 whatever the engine printed. Unlike the shortcut&#39;s key this one may be ed25519; the forced command runs in the same environment <code>receive.sh</code> does, with the same <code>BLOGSH_MAX_MB</code> for deliveries.</p>
<p><b>Sending again.</b> A connection can break after the request has gone and before its answer is back, and the sender cannot tell which side of the engine that was. What a second send does:</p>
<ul><li>A question -- <code>version</code>, <code>list</code>, <code>props</code>, <code>queue</code>, <code>drafts</code>, <code>edit</code>, <code>translate</code>, <code>check</code>, <code>doctor</code>, <code>stats</code> -- changes nothing and can be asked as often as it takes.</li><li><code>publish</code>, <code>unpublish</code>, <code>delete</code>, <code>restore</code> and <code>schedule --cancel</code> are answered the second time by the state the first one left: <code>already_published</code>, <code>already_draft</code>, <code>not_found</code>, <code>not_scheduled</code>. That refusal is the news that the first one arrived.</li><li>A new post delivered twice is two posts -- unless its file carries <code>receipt:</code>, a name the sender makes up once for the post (sixteen hexadecimal characters), and then the second delivery is the first one arriving again and updates the post it wrote (<a href="/posts/2026/writing-from-a-phone/#a-post-sent-from-the-phone-itself">A delivery that arrives twice is one post</a>). The line is the apps&#39; as much as the page&#39;s, and stays when <code>/write/</code> goes.</li><li>An edit or a translation -- a file saying <code>edits:</code> and <code>base:</code> -- delivered twice is <code>changed</code> the second time: <code>base:</code> is the digest of the post&#39;s file, and the first delivery changed it. The answer to a save does not carry the new digest, so <code>edit --json</code> (or <code>translate --json</code>) is asked again before the next save, and after a <code>changed</code> whose cause may have been the sender&#39;s own first try.</li><li>After <code>timeout</code> on an action the engine was stopped where it stood, and the action may be half done: a <code>publish</code> written to the archive and not yet on the site answers <code>already_published</code> from then on, while its address is a 404 until the site is built -- by the next scheduled run, which owes it, or by <code>rebuild --json</code>. Ask <code>props</code> what state the post is in, and <code>rebuild</code> if it is out.</li></ul>
<p>The forced command runs in the account&#39;s non-interactive shell, which reads none of the files a terminal session reads -- so a Ruby that lives under rbenv or Homebrew is not on its <code>PATH</code>, and the engine refuses the system&#39;s old one. Put the path in the key&#39;s line, the way <code>BLOGSH_MAX_MB</code> goes there: <code>command=&quot;env PATH=/home/me/.rbenv/shims:/usr/bin:/bin /path/to/blog/scripts/remote.sh&quot;</code>. (<code>env</code> in front makes it a line every login shell runs: <code>PATH=... script</code> alone is sh&#39;s way of saying it, and an account whose shell is csh or tcsh takes it for a command&#39;s name.) A path with a space in it is single-quoted inside the double quotes; the app writes the line that way itself.</p>
<p>Where the blog lives in a container, the forced command is the one that enters it, and the word SSH was asked for -- which the script reads from <code>SSH_ORIGINAL_COMMAND</code> -- does not cross into the container by itself. Hand it over as the script&#39;s argument, which it takes the same way:</p>
<p>restrict,command=&quot;sudo docker exec -i blog /app/blog/scripts/remote.sh \&quot;$SSH_ORIGINAL_COMMAND\&quot;&quot; ssh-ed25519 AAAA... app</p>
<p><code>-i</code> is what carries the request in; the account needs to run that one command without a password. The app writes this line too, from the blog&#39;s place and the command it is reached through. Keep the quotes around <code>$SSH_ORIGINAL_COMMAND</code>: the word is handed on as one argument, and split into several, what the caller sent would be read as more than the command -- as the word a device&#39;s line says of itself, below.</p>
<h2 id="letting-an-app-in-by-a-code">Letting an app in by a code</h2>
<p>Writing that line by hand stays what it is, and for whoever would rather not there is a second way beside it:</p>
<pre class="code-block"><code class="language-bash">./blog.sh pair                    # shows a code and waits for the app
./blog.sh pair --list             # the devices let in this way
./blog.sh pair --revoke 2         # takes one out: its number, name or fingerprint from --list</code></pre>
<p><code>--list</code> numbers the devices, and <code>--revoke</code> takes that number, the device&#39;s name (quotes or no quotes: <code>--revoke motorola edge 70 fusion</code>) or its fingerprint. With nothing after it, somebody at a terminal is asked which one, and nothing goes without a yes.</p>
<p><code>pair</code> draws a QR code in the terminal (and prints the same thing as a line of text, for an app with no camera to point). The code wants a window of some sixty columns by thirty-five rows, which is more than a terminal opens with, so a window too small is asked to grow first; one that does not is told how large the code is, and the line of text is there either way. The app reads it and is connected; nobody types an address, a user name or a key. What the code holds is where to connect -- the address, the port, the account -- the fingerprints of this server&#39;s own SSH keys, so the app knows it reached this machine, and a key that is good for <b>one thing, once, for ten minutes</b>: handing in the public half of a key the app made itself. The engine then replaces the code&#39;s line in <code>~/.ssh/authorized_keys</code> with an ordinary one for the app&#39;s key, held to <code>scripts/remote.sh</code> like a line written by hand. The key the app lives on never leaves the device, and the one that was on the screen opens nothing once it has been used.</p>
<p>A device&#39;s line says one thing a line written by hand does not: whose it is. Its command ends <code>.../scripts/remote.sh&#39; --device=&lt;fingerprint&gt;</code>, the fingerprint of the key on that same line. sshd tells a forced command nothing about the key that was used, so that word is how one device is told from another -- and the only thing it is told by: not by anything a request says, and not by the environment a session arrives with. A device let in before lines said this gets the word the next time <code>./blog.sh pair</code> is run at a terminal -- <code>--list</code> is enough -- and nothing else on its line moves. (By the account itself: under <code>sudo</code> the lines are left as they are, because a file replaced by root is root&#39;s, and sshd reads it as the account.)</p>
<p>What <code>pair</code> does to <code>authorized_keys</code>, since that is the file that decides who may log in:</p>
<ul><li>It writes only lines that begin <code>restrict,</code> and whose <code>command=</code> names a script of this installation: <code>restrict,expiry-time=&quot;...&quot;,command=</code> for a code that waits, <code>restrict,command=</code> for a device. What an app hands in is taken only if it is exactly one ed25519 public key.</li><li>Where <code>authorized_keys</code> is a link -- kept with your dotfiles, say -- the link stays a link and the file it points at is the one rewritten. The file keeps who may read it (one you made read-only stays so) and is never left writable by anybody else.</li><li>It touches only lines it wrote itself, known by the comment that ends them (<code>blog.sh:&lt;device&gt;</code>, and <code>blog.sh-pair:&lt;id&gt;</code> while a code waits) together with this installation&#39;s path. Every other line -- a key of your own, another blog&#39;s devices -- is written back as it was.</li><li>The file as it was before each change is kept beside it as <code>authorized_keys.blog-sh.bak</code>.</li><li>With a code closed again, or a device come and gone, the file is byte for byte what it was: carriage returns on lines that are not <code>pair</code>&#39;s stay, and so does a last line with no line feed after it.</li><li><code>pair --list</code> only reads: it makes no folder and takes no lock unless there is a run-out code&#39;s line to take out, or a device&#39;s line from before lines said whose they are to bring up to date. Asked through the door it writes nothing at all.</li><li>A line names this installation by its folder. Move the folder and its devices&#39; lines still name the old one: sshd finds no script there, the apps are refused, and neither <code>pair --list</code> nor <code>doctor</code> sees the lines any more, because they are no longer this installation&#39;s. Take them out of <code>authorized_keys</code> by hand (they end <code>blog.sh:&lt;device&gt;</code>) and pair the devices again from the new place.</li></ul>
<p>The ten minutes are kept by the engine: the code&#39;s key can do nothing but ask it, and it refuses a key that arrives late. The code&#39;s line also carries sshd&#39;s own <code>expiry-time</code> as a second lock; that one is coarse (sshd reads it to the minute, and in summer time an hour late), so a line nobody came for may sit in the file that much longer before sshd itself refuses it. The next <code>./blog.sh pair</code>, of any kind, removes it, and <code>doctor</code> mentions one that is left.</p>
<p>It needs the blog and the SSH server in the same account: a plain server, or a Mac with Remote Login turned on. <code>pair</code> checks that SSH answers before it makes a code, and asks for the one thing it cannot know -- the address this machine has from where the phone is. What it offers is the machine&#39;s number on its network, which a phone on the same network can always use; a name works where the phone can look it up (<code>--host</code> and <code>--port</code> say it without being asked; <code>--no-wait</code> prints the code and leaves). Where the blog lives in a container the keys are the host&#39;s and <code>pair</code> cannot reach them; that setup is written by hand, as above. <code>BLOG_SH_AUTHORIZED_KEYS</code> names the file for an account whose sshd reads another one. Write it into <code>env.sh</code>: <code>pair</code> and <code>doctor</code> both read it there, so the list of devices and the diagnosis are about the file the code was written into. Said only in a terminal it is gone with the terminal, and the next <code>pair --list</code> looks into the account&#39;s default file and finds nobody. What sshd itself is set to read the engine cannot see; that the two agree is yours to check (<code>sshd -T | grep -i authorizedkeysfile</code>, as root).</p>
<p>For whoever writes an app: the code is a link, <code>blogsh://pair?v=1&amp;h=&lt;host&gt;&amp;p=&lt;port&gt;&amp;u=&lt;user&gt;&amp;k=&lt;key&gt;&amp;f=&lt;prints&gt;&amp;n=&lt;site&gt;</code>, its values written as a web form writes them -- a space in the site&#39;s name is a <code>+</code>, and a <code>+</code> would be <code>%2B</code>. <code>k</code> is the 32 bytes of an ed25519 private key in URL-safe base64 with no padding; <code>f</code> is the SHA-256 fingerprints of the server&#39;s host keys in the same alphabet, joined by dots, and may be absent. Connect with that key, refuse a server whose key is not among the fingerprints, ask for the command <code>enroll</code> and write one line of JSON, <code>{&quot;key&quot;: &quot;ssh-ed25519 AAAA...&quot;, &quot;name&quot;: &quot;the device&#39;s name&quot;}</code>. The answer is one object: <code>{&quot;ok&quot;: true, &quot;device&quot;: &quot;...&quot;}</code>, or <code>ok: false</code> with <code>error</code> one of <code>expired</code>, <code>used</code>, <code>unknown_code</code>, <code>bad_key</code>, <code>bad_request</code> -- and, when it is the machine rather than the code that is at fault, <code>engine_failed</code> (with the engine&#39;s words in <code>message</code>), <code>no_ruby</code>, <code>no_cd</code> or <code>unknown_command</code>. <code>key_in_use</code> is a key that already stands on another line of the account&#39;s file -- another blog&#39;s, or one written by hand: sshd runs the first line a key is on and never a second, so an app makes one key for each blog it connects to. From then on the app&#39;s own key works as any app&#39;s does.</p>
<p>A device let in this way can show the others and take itself out. Both are <code>run</code> requests like any other:</p>
<pre class="code-block"><code>{&quot;args&quot;: [&quot;pair&quot;, &quot;--list&quot;]}
{&quot;ok&quot;: true,
 &quot;devices&quot;: [{&quot;name&quot;: &quot;Daniel&#39;s iPhone&quot;, &quot;fingerprint&quot;: &quot;SHA256:k/JB...QCo&quot;, &quot;self&quot;: true},
             {&quot;name&quot;: &quot;Tablet&quot;,          &quot;fingerprint&quot;: &quot;SHA256:wL8H...u+w&quot;, &quot;self&quot;: false}],
 &quot;self&quot;: &quot;SHA256:k/JB...QCo&quot;}

{&quot;args&quot;: [&quot;pair&quot;, &quot;--revoke&quot;]}
{&quot;ok&quot;: true,
 &quot;revoked&quot;: {&quot;name&quot;: &quot;Daniel&#39;s iPhone&quot;, &quot;fingerprint&quot;: &quot;SHA256:k/JB...QCo&quot;},
 &quot;devices&quot;: [{&quot;name&quot;: &quot;Tablet&quot;, &quot;fingerprint&quot;: &quot;SHA256:wL8H...u+w&quot;, &quot;self&quot;: false}],
 &quot;self&quot;: null}</code></pre>
<p><code>devices</code> are in the order <code>pair --list</code> numbers them at the terminal, and a fingerprint is written as <code>ssh-keygen -l</code> writes it: <code>SHA256:</code> and 43 characters of base64 with no padding (an empty string for a line whose key somebody&#39;s hand has left unreadable). <code>self</code> -- on each device, and once beside the list -- says which of them is asking; it is <code>null</code>, and every device&#39;s is <code>false</code>, for a key whose line does not say whose it is: one written by hand, or one of a pairing older than the word, until <code>./blog.sh pair</code> has been run at the terminal once -- and where the engine reads another key file than sshd does (<code>BLOG_SH_AUTHORIZED_KEYS</code> belongs in <code>env.sh</code>; one a session arrives with is not listened to). An app shows the list all the same and offers no way out.</p>
<p><code>--revoke</code> takes out the device that is asking and no other; nothing may follow it. The request is answered -- sshd does not end a session because the key&#39;s line has gone -- and the next connection with that key is refused by sshd itself, so an app forgets the blog when it reads <code>ok: true</code>. (Every line of this blog that holds the key goes, should there be more than one. A line that is not this blog&#39;s -- one written by hand, another blog&#39;s -- stays, and a key that stands on one still opens what that line opens.) The refusals: <code>device_unknown</code> when the blog cannot tell who is asking (<code>self</code> was <code>null</code>), <code>busy</code> when somebody else is changing the file at that moment (nothing was taken out; ask again), <code>write_failed</code> and <code>read_failed</code> when the file could not be written or read, <code>keys_path</code> when <code>BLOG_SH_AUTHORIZED_KEYS</code> names a path that cannot be expanded, and <code>bad_args</code> for anything else after <code>pair</code> -- a code, an address, a name. Taking out ANOTHER device is done at the terminal, by whoever can sit at it: <code>./blog.sh pair --revoke</code>. A blog whose app was set up by hand answers <code>--list</code> with no devices, since none came in by a code.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Pinning a post to the front page</title>
  <link>https://manual.blogsh.app/posts/2026/pinning-a-post-to-the-front-page/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/pinning-a-post-to-the-front-page/</guid>
  <pubDate>Mon, 12 Oct 2026 10:59:00 +0200</pubDate>
  <description><![CDATA[<p>The <code>[c]</code> action in <code>./blog.sh props &lt;slug&gt;</code> pins a published post -- or unpins it again; <code>pinned: true</code> in the post&#39;s header (<code>./blog.sh edit &lt;slug&gt;</code>) does the same thing the long way round. A pinned post is held at the top of the front page, marked with a pin in the corner of its date badge. It appears there once: while it is still on the front page anyway it is lifted to the top rather than shown twice, and once it has aged onto <code>/page/2/</code> the front page keeps the copy at the top while page 2 lists it in its normal place, unmarked.</p>
<p>Only the front page. Type and tag listings, the RSS feed, the sitemap and the search index stay strictly chronological -- a pin is a statement about the front page, not about the archive. Toggling it is a small deploy, because pagination is anchored and the front page is the only flexible one. Pin a second post and the newest of them wins, with a warning in the build output.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Publishing slots</title>
  <link>https://manual.blogsh.app/posts/2026/publishing-slots/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/publishing-slots/</guid>
  <pubDate>Mon, 12 Oct 2026 10:58:00 +0200</pubDate>
  <description><![CDATA[<p>Set the times posts usually go out and <code>[s]</code> stops asking for a date:</p>
<pre class="code-block"><code class="language-yaml">publishing:
  slots:
    - &quot;mon 09:30&quot;
    - &quot;wed 09:30&quot;
    - &quot;fri 09:30&quot;
    # or a single &quot;daily 09:00&quot;</code></pre>
<p>The draft dialog then names the next FREE slot in the <code>[s]</code> choice itself, and the prompt offers it: Enter accepts, typing a date overrides it, the cancel word backs out. Free means no other scheduled post is aimed at that exact time, so three drafts written in one evening queue onto three consecutive slots instead of publishing together, and the confirmation says which post goes out before this one.</p>
<p>The offer also names the earlier slots it had to walk past and the post sitting in each, so a queue that seems to skip a day explains itself. The properties dialog of a scheduled draft prints the whole queue for the same reason, with an arrow on the post you are looking at.</p>
<p>Slots only ever suggest. A post scheduled by hand for 14:17 occupies no slot and blocks nobody, nothing moves a post that already has a time, and without the key in <code>config/site.yml</code> the prompt is the plain one it always was. Times follow <code>site.timezone</code>, daylight saving included -- &quot;mon 09:30&quot; is 09:30 on the wall clock on both sides of the change. The <a href="/posts/2026/cron-sidebar-widgets-and-post-stats/">publish-scheduled cron</a> still runs on its interval, so a slot publishes within one tick of its time.</p>
<h2 id="working-the-queue">Working the queue</h2>
<p><code>./blog.sh queue</code> (also a wizard menu entry) shows every scheduled post in publish order and acts on the one you pick:</p>
<ul><li><code>[u]</code> / <code>[d]</code> move it a slot earlier or later. Moving exchanges times with the neighbouring post -- the set of occupied times never changes, only which post sits in which. A hand-scheduled 14:17 stays a 14:17. The cursor follows the post you moved, so pressing <code>[u]</code> again carries the same post further.</li><li><code>[m]</code> picks the post up and carries it: the arrows then move the post itself through the queue, Enter puts it down and Escape leaves the queue as it was. Carrying from the eighth slot to the second steps the six in between back one slot each, so the same times stay occupied and the whole move is one write instead of one per slot. For a single slot <code>[u]</code>/<code>[d]</code> are quicker; <code>[m]</code> is for the longer trip, and it is offered in a terminal only -- a piped run has <code>[u]</code>/<code>[d]</code> and no screen to carry anything across.</li><li><code>[p]</code> publishes it right now, the same flow as publishing a draft by hand (announcement included).</li><li><code>[s]</code> asks for a different time, same prompt as scheduling.</li><li><code>[n]</code> returns it to the drafts; the post keeps its text, loses only the plan.</li></ul>
<p>When a post leaves the queue -- published now, or removed -- its time is free again, and the screen offers to let the posts behind it each step forward into the gap, every one taking over its predecessor&#39;s time. It only offers: a hand-picked date further down may be deliberate, and nothing moves a post&#39;s time except you. A post whose time already passed is waiting for the cron and can&#39;t be reordered.</p>
<p>If the publishing cron happens to be running at the moment you move something, the move is refused rather than written: the two would be writing the same files, and the cron holds them for a few seconds at a time. Press the key again in a moment.</p>
<p>The preview rebuilds once, when you leave the screen, not after every move.</p>
<p><code>./blog.sh queue --json</code> prints the queue as data instead -- one row per scheduled post in publish order, with its position, time, slug, year, title and whether its time has already passed -- and acts on nothing. Beside the rows it says when something last ran the queue (<code>scheduler.last_run</code>), or null where nothing ever has: on such a blog a post past its time is nobody&#39;s to wait for, and both the screen and the refusal say so and point at <code>./blog.sh publish &lt;slug&gt;</code>.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Attachments and the document type</title>
  <link>https://manual.blogsh.app/posts/2026/attachments-and-the-document-type/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/attachments-and-the-document-type/</guid>
  <pubDate>Mon, 12 Oct 2026 10:57:00 +0200</pubDate>
  <description><![CDATA[<p>A line that is nothing but <code>[label](handbook.pdf)</code> -- a bare filename, whitelisted extension -- makes the file part of the post: it is picked up from <code>incoming/</code> exactly like a photo, stored in <code>media.nosync/&lt;year&gt;/&lt;slug&gt;/</code>, and rendered as a download card showing the label, the extension and the file&#39;s size. A link to an address stays a link; the engine can only publish files it was handed.</p>
<p>Whitelisted: <code>.pdf .zip .tgz .epub .txt .md .ics .gpx .csv</code> (<code>.tar.gz</code> is not, because only the last suffix survives the rename -- use <code>.tgz</code>). A post whose text is a short line plus attachments is filed under DOCUMENTS, which appears in the nav once the first such post exists; a longer article that attaches its data stays an article with a file on it.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Importing from another platform</title>
  <link>https://manual.blogsh.app/posts/2026/importing-from-another-platform/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/importing-from-another-platform/</guid>
  <pubDate>Mon, 12 Oct 2026 10:56:00 +0200</pubDate>
  <description><![CDATA[<p><code>./import.sh</code> opens its own wizard: pick a source, and it reads the whole thing in dry-run first and tells you what <i>would</i> be written -- how many posts and media files, the first few slugs, and how many items it skipped and why. Nothing is written until you confirm, and confirming means typing the number of posts rather than pressing a key -- an answer you can&#39;t give without having read the preview. The sources cover blog and newsletter platforms (WordPress, Blogger, Ghost, Medium, Substack, a Jekyll/Hugo tree, ...), the social networks (Twitter/X, Mastodon, Bluesky, Instagram, ...), podcast feeds, and the Wayback Machine for a blog whose platform no longer exists at all. The wizard groups them by that question -- a blog you published, a network you posted to, a dead site -- and the full list lives in <a href="/series/importing/">Importing</a>.</p>
<p>Every source also runs without the wizard, for cron or a scripted migration -- one <code>scripts/migrate_&lt;source&gt;.rb</code> each, e.g. <code>scripts/migrate_bluesky.rb &lt;handle&gt;</code>, <code>scripts/migrate_instagram.rb &lt;export-dir&gt;</code>, <code>scripts/migrate_wayback.rb &lt;<a href="https://dead-blog.example">https://dead-blog.example</a>&gt;</code>, <code>scripts/migrate_feed.rb &lt;export.xml | feed-url&gt;</code>. Those skip the preview and write immediately; see <a href="https://github.com/DanielSnor/blog.sh/blob/main/README.md#importing-existing-content">the README</a>.</p>
<p><b>A wizard that wrote nothing says so with its exit code.</b> Cancelling leaves with 1, and so does reaching the end of the input without an answer -- which is what a script, a cron line or a pipe does. Zero means the import ran; it never means &quot;nobody answered, so nothing happened&quot;.</p>
<p>Two things to expect on a real archive:</p>
<ul><li><b>It is slow, and it says so.</b> Media is downloaded per post, so a few thousand posts run for hours. Every phase reports progress -- what it&#39;s reading, how many items it found, then a <code>12/847</code> counter -- so a quiet terminal means something is wrong, not that it&#39;s working. Sample before committing to that: every script takes <code>LIMIT=20</code> to import only the first twenty, which is enough to see whether the mapping does what you expect. A second full run then overwrites them in place.</li><li><b>The deploy guard will stop you afterwards</b>, because a bulk import is exactly the &quot;file count swung wildly&quot; shape it watches for. That&#39;s working as intended: check the numbers, then re-run with <code>--force</code>.</li><li><b>The summary names what HTML could not become blocks.</b> Every adapter that parses HTML bodies counts the elements it had to drop -- embeds and forms, usually -- and the summary lists them by name and count, so a silent summary means nothing was lost, not that nobody counted.</li></ul>
<p>Per-source walkthroughs -- where to get each export, what is kept and skipped, undo, troubleshooting -- live in <a href="/series/importing/">Importing</a>.</p>
<p>Re-running an import is safe -- posts are matched on their source identity and overwritten in place, never duplicated (<a href="/posts/2026/what-every-import-does/">Importing</a>); the same identity is the safe selector when <a href="/posts/2026/undoing-an-import/">undoing an import</a>.</p>
<p>Back up <code>content.nosync/</code> before the first real import (<code>tar czf ../content-backup-$(date +%F).tar.gz content.nosync</code>) -- it isn&#39;t in git, and on a server there&#39;s nothing else to fall back on.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Taking your content elsewhere</title>
  <link>https://manual.blogsh.app/posts/2026/taking-your-content-elsewhere/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/taking-your-content-elsewhere/</guid>
  <pubDate>Mon, 12 Oct 2026 10:55:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">./blog.sh export ~/my-blog-export        # everything, drafts included
./blog.sh export ~/public-copy --no-drafts
./blog.sh export ~/somewhere --dry-run   # counts, writes nothing</code></pre>
<p>Writes the archive as a tree of markdown files with YAML front matter, in Jekyll&#39;s layout: <code>_posts/2026-05-01-slug.md</code>, <code>_drafts/slug.md</code>, pages at the root, media copied under <code>assets/&lt;year&gt;/&lt;slug&gt;/</code>. Without a directory it writes to <code>tmp/export</code>. A directory that already has something in it is refused until you repeat the command with <code>--force</code>, which writes alongside what is there -- an export never deletes anything, at either end. With <code>--force</code>, files an earlier export of this site wrote are refreshed in place; anything else -- a cloned site&#39;s own <code>_config.yml</code>, a post of somebody else&#39;s under the same file name, a different picture under the same path -- is left exactly as it is, the post of ours goes beside it under another name, and the summary lists what was left alone.</p>
<p>A post that cannot be written -- a stray byte from an old import, a slug that is not one path segment -- is named at the end and the rest of the archive still comes out; the command then exits 1, so a backup made by cron is seen to be short.</p>
<p>It reads only the archive on disk -- deliberately, so it works on an installation whose <code>env.sh</code> is gone or whose config no longer parses.</p>
<p>Three things are worth knowing before you rely on the result:</p>
<ul><li><b>The summary counts what could not stay markdown.</b> Video, audio and link cards are written as HTML -- video and audio deliberately, because the engine&#39;s own <code>!![caption](url)</code> reads as an exclamation mark and an image to everybody else&#39;s markdown parser. Every engine that passes HTML through renders them properly, and importing the tree back gives them again as blocks -- each carries its own definition in a comment above it and a closing <code>&lt;!-- /blogsh:block --&gt;</code> line under it, both of which other engines ignore. (Trees exported before 1.8 have no closing line and are still read; an embed with a blank line inside it came home from those with part of its markup as text.) It also says how many of the media files copied no block names: they are in the tree, but an import back brings home only what the posts refer to.</li><li><b><code>redirect_from</code></b><b> is written in the shape </b><b><code>jekyll-redirect-from</code></b><b> reads,</b> and it merges both kinds of old address -- where the post lived on the platform it came from, and where it lived here before a rename. On a Jekyll site with that plugin, every address the post has ever had goes on answering.</li><li><b>An export can be imported back, into an EMPTY archive.</b> <code>./import.sh</code> → <i>Markdown tree</i> reads the <code>blogsh:</code> block the export writes, so posts keep their series, their redirects, their announcement URLs and their other languages -- a post&#39;s translations ride under <code>blogsh:</code> whole, with the address and the old addresses each language has, since no other engine has a word for them, and with each file their blocks show named by the path it has in the tree, so the import brings it and gives both texts the one name it has afterwards (the body of the file is the post&#39;s own language; what the site says about itself in the other ones is <code>config/site.&lt;lang&gt;.yml</code>, which is configuration and travels with the backup, not with the export). That is the supported way to move an installation to another machine or another host, and -- run against a scratch copy -- the way to check that an export really did come out whole.</li></ul>
<p>⚠️ <b>Not into an archive that already holds those posts.</b> A post that carries an identity of its own -- imported from Twitter, Ghost, a feed -- is matched by it and updated in place. Everything you typed yourself carries <code>{platform: manual}</code> and nothing else, and the engine refuses to match two of those on purpose: pairing them would overwrite one person&#39;s writing with another&#39;s. So each hand-written post is written AGAIN under a serial slug. Every other rule in <code>check</code> lets the copy pass, because it has an address of its own; only its &quot;looks like a copy of another&quot; finding catches it, and only after the fact. On a blog whose posts are all hand-written that is the whole archive, twice. The wizard recognises this site&#39;s own export by its posts -- the <code>blogsh:</code> key every exported post carries, and slugs this archive already has -- which works for exports made by any version, and says so before it asks for confirmation. A tree of ours going into an archive that already holds posts is also asked about outright, with no as the default. But the rule is simpler than the warning: the target should be empty.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Reading the archive</title>
  <link>https://manual.blogsh.app/posts/2026/reading-the-archive/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/reading-the-archive/</guid>
  <pubDate>Mon, 12 Oct 2026 10:54:00 +0200</pubDate>
  <description><![CDATA[<p>Three pages the build writes for you, all from the posts themselves -- there is nothing to configure and nothing to keep up to date.</p>
<p><code><b>/archive/</b></code> is a map of the site in two levels. The first is a row per year with a strip of twelve months, each month shaded by how much was written in it; the second, <code>/archive/&lt;year&gt;/</code>, is one line per post, grouped by month, oldest first. No excerpts and no pictures: this is an index, and its whole point is that twenty years fit on one screen. Pagination cannot do that -- it is anchored to the oldest post, so <code>/page/128/</code> says nothing about whether it holds 2009 or 2014. Every post&#39;s date badge links into the month it belongs to, so a reader who finds one post can see what surrounded it.</p>
<p>Under the map a legend says what the shading means -- the four steps between &quot;less&quot; and &quot;more&quot;, in the map&#39;s own colours. A year&#39;s page opens with that year&#39;s row of the map again, behind the words &quot;jump to a month&quot;: a month with posts is a link to its section further down the page, shaded as it is on the map.</p>
<p>A year nobody wrote in gets a row on the map and no page of its own: an empty page is an invitation to a dead end. A post is filed under the year of its ADDRESS, so <code>/archive/2025/</code> and <code>/posts/2025/</code> always agree.</p>
<p><code><b>/tag/</b></code> is every tag the site has, as pills, with a superscript count of how often each was used. It is built alphabetically -- folded, so an accented name sorts where a reader expects rather than after z -- and a reader can switch it to by-count, which is remembered for next time. Only tags that have a page of their own appear, so the list never points at an address the build did not write. Above the list a row of letters -- the ones that have tags under them -- jumps to each letter&#39;s band; by-count order has no bands, so the row is put away while that order is shown.</p>
<p><code><b>/series/</b></code> is every series the site has, a line each: the name, how many parts, and the years they span. The one still being written comes first -- the order is by the newest part. A series is listed once it has a page of its own, which is once it has two published parts, and the name in a series&#39; own heading leads back here, as a tag&#39;s leads to <code>/tag/</code>. A site with no series has no such page.</p>
<p>None of the three needs a menu item to work, but <code>nav:</code> in <code>site.yml</code> is where you would put one; see <b>Configuration</b> in the README.</p>
<p><b>A picture post&#39;s card carries the picture.</b> A card in a listing is cut by height, and it keeps its first block whatever that weighs -- so a photograph under one line of words used to be left off the card of a post that is that photograph. For a post whose kind is image or video (the icon in its date badge says which) the medium now goes first and the cut runs over what follows. A medium taller than it is wide is held to a height rather than given a screen to itself. An article with a photograph in it is still an article, and its card is cut as it always was.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Giving a tag its own icon</title>
  <link>https://manual.blogsh.app/posts/2026/giving-a-tag-its-own-icon/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/giving-a-tag-its-own-icon/</guid>
  <pubDate>Mon, 12 Oct 2026 10:53:00 +0200</pubDate>
  <description><![CDATA[<p>A tag can carry an icon, and it shows up in two places: the heading of its <code>/tag/&lt;name&gt;/</code> listing, and the date badge of every post that has the tag, where it <b>replaces</b> the content-type icon rather than joining it.</p>
<pre class="code-block"><code class="language-yaml">tag_icons:
  - tag: &quot;fotografie&quot;
    icon: &quot;image&quot;
  - tag: &quot;kolo&quot;
    icon_svg: &#39;&lt;svg viewBox=&quot;0 0 24 24&quot; ...&gt;...&lt;/svg&gt;&#39;</code></pre>
<p>A tag is matched here by its <b>address</b> -- the <code>/tag/&lt;name&gt;/</code> its listing lives at -- so how the name is spelled, here or in a post, makes no difference: <code>sci-fi</code>, <code>Sci Fi</code> and <code>sci_fi</code> are one tag, share one page and wear one icon. A tag that leaves nothing to be addressed by, an emoji or a piece of punctuation, has no page and takes no icon either.</p>
<p>The order is the priority. Most posts carry more than one tag -- 68% of them on the archive this engine was built around -- and the tag a post was given FIRST is usually the one an importer added, not a subject: <code>twitter</code> opens 1256 posts there. So the first entry in this list that a post has is the one it wears, and the site owner decides once instead of post by post.</p>
<p><code>icon</code> names one the engine ships. Eight of them are the content types themselves -- text, quote, chat, image, video, audio, link, document -- and the rest are the things blogs are about:</p>
<div class="table-wrap"><table><thead><tr><th></th><th></th></tr></thead><tbody><tr><td>getting about</td><td><code>bike</code> <code>car</code> <code>train</code> <code>plane</code> <code>boat</code> <code>walk</code></td></tr><tr><td>places and weather</td><td><code>map</code> <code>pin</code> <code>mountain</code> <code>tree</code> <code>sun</code> <code>cloud</code> <code>rain</code> <code>snow</code></td></tr><tr><td>a day</td><td><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></td></tr><tr><td>making things</td><td><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></td></tr><tr><td>machines</td><td><code>laptop</code> <code>phone</code> <code>code</code> <code>terminal</code> <code>server</code> <code>bug</code> <code>lock</code> <code>key</code></td></tr><tr><td>living things</td><td><code>paw</code> <code>bird</code> <code>leaf</code> <code>flower</code></td></tr><tr><td>ideas</td><td><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></td></tr></tbody></table></div>
<p>They are line drawings on the same grid as the eight, so a badge wearing one looks like a badge. <code>doctor</code> prints the whole list when a name is not among them, which is usually a synonym away -- <code>bicycle</code> for <code>bike</code>. A footer link in <code>social:</code> can wear one too, where the network marks have nothing to offer: <code>icon: globe</code> for somebody&#39;s other site.</p>
<p><code>icon_svg</code> is your own drawing, on the same 24-unit grid (<code>viewBox=&quot;0 0 24 24&quot;</code>) and stroked in <code>currentColor</code> so it follows the light and dark themes. It does not have to be drawn from scratch: open an icon from any set that works on that grid -- Lucide, Feather and Tabler all do -- copy the <code>&lt;path&gt;</code> elements out of its file and paste them between <code>&lt;svg viewBox=&quot;0 0 24 24&quot; width=&quot;20&quot; height=&quot;20&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot;&gt;</code> and <code>&lt;/svg&gt;</code>. Mind the licence of whatever you take, and if the set fills its shapes rather than stroking them, drop the <code>fill=&quot;none&quot;</code> and take <code>stroke</code> out. Scripts, styles and event handlers are stripped out of it before it reaches a page -- the same treatment an imported embed gets, and for the same reason. <code>doctor</code> says when an icon name is one the engine does not have, when an <code>icon_svg</code> holds no <code>&lt;svg&gt;</code>, and when it is drawn to another scale. 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, and the tag falls back to what it would have had with no entry here.</p>
<p>A tag with no entry here changes nothing: its listing keeps the generic tag icon and its posts keep the icon of their content type.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Emptying the trash and the versions</title>
  <link>https://manual.blogsh.app/posts/2026/emptying-the-trash-and-the-versions/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/emptying-the-trash-and-the-versions/</guid>
  <pubDate>Mon, 12 Oct 2026 10:52:00 +0200</pubDate>
  <description><![CDATA[<p>The engine keeps two things after you are done with them, and both have a way back: a deleted post waits in <code>trash/</code> for <code>./blog.sh restore</code>, and every save keeps the previous text of a post, offered by the version picker in <code>./blog.sh props</code>. Neither had a way OUT before 1.6 -- the only way to empty either was <code>rm</code> on the server.</p>
<pre class="code-block"><code class="language-bash">./blog.sh empty trash       # deletes every trashed post for good
./blog.sh empty versions    # keeps each post&#39;s newest version, removes the older ones</code></pre>
<p>Both say how much they are about to delete and confirm by having you type that number -- the way <code>delete</code> has you type the slug. A trash has no slug to repeat back, and the count is the one number you have just been shown, so typing it means having read it. Anything else, including an empty answer, cancels and deletes nothing.</p>
<p><code>empty trash</code> takes the media of a trashed post with it, and covers both shapes the trash has had: today&#39;s <code>trash/&lt;year&gt;/&lt;slug&gt;/</code> and the flat <code>trash/&lt;slug&gt;/</code> of an installation older than that.</p>
<p><code>empty versions</code> deliberately keeps ONE version per post -- the newest. Versions exist to answer &quot;give me back what I just overwrote&quot;, and that answer is the newest one; removing it too would take away the thing they are for.</p>
<p><code>doctor</code> mentions a trash that has anything in it, with its size. Not as a fault -- a trash with posts in it is a trash doing its job -- but because nothing else on the site ever says it is there.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Checking the archive</title>
  <link>https://manual.blogsh.app/posts/2026/checking-the-archive/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/checking-the-archive/</guid>
  <pubDate>Mon, 12 Oct 2026 10:51:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">./blog.sh check            # walks every post and every media file
./blog.sh check --online   # also asks whether the links that leave the site still answer
./blog.sh check --json     # the findings as data: every one of them, no screen</code></pre>
<p>Both answer as data when asked: <code>doctor --json</code> and <code>check --json</code> print one object each, every finding in it, for a program rather than a reader -- and <code>check --languages --json</code> prints the table of languages the same way.</p>
<p><code>doctor</code> asks whether the installation is sound and takes a second; this asks whether the <i>archive</i> is, and walks all of it -- two commands so the fast half keeps being run, and it is the fast half that belongs before a deploy. <code>check</code> reads the content on disk, so it works before a build has ever run, and it names a post and a slug for every finding rather than a file under <code>public.nosync</code>: something to go and fix.</p>
<p>The one thing it looks at outside the archive is <code>config/site.yml</code>, and for the same reason: this is the command people run before a build, and a config the build will refuse is not something to find out afterwards. It reports that file in <code>doctor</code>&#39;s words, so the two never describe it differently.</p>
<p>What it looks for, each with a line saying what to do about it:</p>
<ul><li><b>A </b><b><code>config/site.yml</code></b><b> that is missing, empty, will not parse, or cannot be opened.</b> The build stops on all four and <code>doctor</code> explains all four; <code>check</code> used to read the config only to pick the language it printed in and say nothing about it, so an edited config could be answered with &quot;the archive is sound&quot; and an exit code of 0. The parse error carries the line and column Psych knew about.</li><li><b>A post file that will not read, a date nothing can parse, a post whose text is not a list of blocks, a slug or a draft token that is not one path segment (a slash or a </b><b><code>..</code></b><b> in it), tags that are not a list, a block whose pictures, poster, list items or formatting are not a list, or a </b><b><code>type:</code></b><b> the engine does not know.</b> The build refuses to run on all but the last, or writes the page nowhere good, so check says so first: without this it counted the archive minus the broken file and called the rest sound. The unknown type is stored on the post and read by nobody -- no listing, no menu entry, no icon -- and the fix line points at the tag route, which is usually what somebody reaching for a ninth type wanted: a tag named in <code>nav:</code> has a listing, pagination, a menu entry and a feed.</li><li><b>A post file the build never reads.</b> Posts are read from exactly <code>content.nosync/posts/&lt;folder&gt;/&lt;post&gt;.json</code>; one lying directly in <code>posts/</code> or a level deeper -- an rsync with the wrong trailing slash, an export unpacked one folder too far in -- is in the archive and on no page, and neither the build nor check used to say so. A warning, with the path. Hidden files are left alone.</li><li><b>A file a queue move stepped aside and a crash left parked.</b> The parking name is dotted precisely so no listing shows it -- which also means nothing would ever find one again without this. What to do with it depends on whether the post inside is still in the archive somewhere: a copy of a post that landed is a leftover to compare and delete, a parked file that is the only copy of its post is one to put back under a free name, and the finding says which of the two it is looking at. It never tells you to write over the post standing at that name.</li><li><b>Media a post asks for and hasn&#39;t got</b> -- a video&#39;s poster image included -- usually an import whose download failed. The page renders a hole. A file that is there and useless -- empty, unreadable, or a folder under the picture&#39;s name -- is reported the same way, with a sentence that says which it is; and a video whose index sits at the end of the file is noted, since it plays but makes every reader wait for the whole download before the first frame.</li><li><b>Images stored as 1px or smaller.</b> The build treats those as tracking pixels and drops them <i>together with their caption</i>, so the page loses both without saying so.</li><li><b>Internal links pointing at nothing.</b> Typically a permalink left over from an import, or a slug renamed back before renames left a redirect behind.</li><li><b>Links written relative to the post rather than to the site</b> -- <code>./?item=another-post</code>, <code>photo/index.php?gallery=3</code>, <code>../about/</code>. A dynamic site could afford those; a static one resolves them inside the post&#39;s own address, and a static host ignores a query string, so the link answers 200 with the page the reader is already on. Nothing breaks and nobody arrives, which is why these survive audits: rewrite them as rooted paths. A bare <code>#fragment</code> is left alone -- resolving against its own page is the point of it.</li><li><b>Media directories no post owns</b> -- left by a deleted or renamed post, or an import that ran twice. Nothing links to them; they cost disk, not correctness, which is why they are a warning. Names are compared the way macOS and iCloud compare them, ignoring letter case and unicode form, so a directory restored in another form is not called orphaned while the post is reading its pictures from it.</li><li><b>Files in a post&#39;s own media directory that the post no longer names</b> -- a source that dropped a picture leaves its file behind, because an import only ever adds. A warning too; dotfiles are left alone.</li><li><b>Two series whose names differ by a character or two</b> -- usually one series with a typo that quietly founded its own. A warning; names differing only in digits are left alone (<code>rok-2025</code> next to <code>rok-2026</code> is two year-series, not a typo).</li><li><b>One old address claimed by two posts.</b> Whichever renders last wins and the other&#39;s readers land on it.</li><li><b>Two posts that would be served at one address.</b> The build refuses to run at all in this state, so this is the one finding that stands between you and a site that cannot be rebuilt. Two addresses that differ only in letter case or in how an accent is encoded (<code>Praha</code> and <code>praha</code>) are reported as well, as a warning: on a Linux server they are two, on macOS and in iCloud they are one folder, and one of the posts is missing from the site.</li><li><b>A post that looks like a copy of another</b> -- <code>x-2</code> beside <code>x</code> in the same year, with the same title, the same moment and the same content (text compared by its words, every other block -- a link, a quote, a list, a picture -- compared whole). That is what importing an export back into an archive that already held it leaves behind: every hand-written post a second time, each under its own address, so nothing else here would object. A warning, not an error, and deliberately narrow -- a slug that ends in a number beside the same slug without one is ordinary (an importer that keeps the source&#39;s post id in the slug makes exactly that), so all four have to agree before it says anything. Two link posts that open with the same line and point at different articles are not copies, and are not reported. Delete the numbered copy; the original keeps its address.</li><li><b>A </b><b><code>redirect_from</code></b><b> the build will not serve</b> -- one whose first segment belongs to the site itself (<code>/tag/...</code>, <code>/posts/...</code>), or whose shape no directory can be made of. The build says so once, in the middle of a log nobody keeps, and the old address quietly 404s; worse, a link pointing at it used to pass as sound. The same goes for an old address -- in <code>redirect_from</code> or <code>former_slugs</code> -- that a live post or page now stands at: the build serves the live one and skips the redirect in one line of its log, so every link to the old address reaches the wrong post.</li></ul>
<ul><li><b>Text carrying HTML entities instead of the characters they stand for.</b> <code>journalists &amp;amp; writers</code> reads as <code>journalists &amp;amp; writers</code> on the page: the build escapes it again, correctly, because as far as it knows the ampersand is what you wrote. Twitter escapes <code>&amp;</code>, <code>&lt;</code> and <code>&gt;</code> in its archive without saying so, and the importer only learned to decode them in 1.4 -- so an archive imported before that carries them, and upgrading cannot help, since by then they are in the posts. Reported rather than corrected: somebody writing <i>about</i> html has every right to <code>&amp;amp;</code> in their text.</li><li><b>Two posts asking for one address in one language</b> -- a translation and another post that both claim <code>/de/…</code>, which the build would resolve by serving one of them and dropping the other. And a text filed under the site&#39;s OWN language in <code>translations</code>, which nothing reads: in that language the post is its own title and text.</li></ul>
<ul><li><b>A </b><b><code>config/site.&lt;lang&gt;.yml</code></b><b> that nothing reads</b> -- written for a language the site does not publish, carrying a key the engine does not know, or translating something site.yml does not have. All of them do nothing at all, and all of them look like the work is done. And one whose menu or footer links go to other places than site.yml&#39;s: a translation changes the words, not where they lead. A word for a tag no published post carries is worth a look rather than an error: it shows nowhere, and nothing is broken. Worth a look in the same way: a name for a series nobody writes, a menu item or a footer link with no word of its own (shown under the site&#39;s), a title given as nothing (the site&#39;s own stands), a <code>fallback</code> naming a language the site does not publish, and a language with a borrowed interface that does not say what it is called (<code>language_name</code>).</li></ul>
<ul><li><b>A language named in </b><b><code>site.locales</code></b><b> that the engine has no locale file for.</b> A typo there is not a typo in one language: the build stops on it in EVERY language of the site, its own included, because the menu that offers the other languages needs each one&#39;s name. A language the engine has no words for may borrow another&#39;s: <code>ui_language: &lt;code&gt;</code> at the top of that language&#39;s own file, <code>config/site.&lt;code&gt;.yml</code>.</li><li><b><code>site.fallback</code></b><b> or </b><b><code>site.ui_language</code></b><b> in </b><b><code>config/site.yml</code></b><b>.</b> That is where a language&#39;s own settings were kept before 1.9 gave each language a file; the build refuses the old place by name, in every language. Each value goes to the top of its language&#39;s <code>config/site.&lt;code&gt;.yml</code>.</li></ul>
<p>It only reports, unless you ask it not to. On its own -- and that is how cron runs it -- nothing here deletes a directory or rewrites a post: the value of the tool is that its output can be trusted, and a checker that also acts has to be trusted twice. <code>--repair</code> is where the asking happens, one finding at a time and never without a key press; it is described below, and it earns its trust the hard way -- it adds rather than rewrites where it can, it moves files to the trash rather than deleting them, and it keeps a version of a post before changing it. Twenty findings of each kind are listed and the rest counted, so one bad import can&#39;t bury everything else. It exits non-zero on errors only, never on warnings, so it can hang off cron and speak up only when something is actually broken.</p>
<p><b><code>--online</code></b><b> additionally asks the web about every link that leaves the site.</b> It takes minutes rather than a second, which is why it has to be asked for by name. What it reports is deliberately narrow: a host that no longer resolves, and a page answering 404 or 410. A timeout, a refused connection, a 5xx or a 403 is the web saying &quot;not right now&quot;, and reporting those would turn one flaky evening into forty findings that are all fine tomorrow. Anything that looks dead is confirmed with a second request before it is believed, because some servers answer a HEAD with 404 and a GET with 200 for the same address. Verdicts are remembered in <code>tmp/link-check.json</code> for two weeks, so running it again next week only asks about the links it hasn&#39;t seen lately; deleting that file just means the next run asks about everything.</p>
<p><code>--repair</code> walks the findings and offers, for each one, the single repair that finding allows -- nothing is applied without a key press. A dead link to an old address is repaired on the <b>target</b> post, by writing that address into its <code>redirect_from</code>: one added line, your own text untouched, and every link to the old address answered at once, including the ones from outside the site that no check can see. That holds for an address of the web a post came from. One the site makes itself -- under <code>/posts/</code>, or under the folder of a language -- is an address no <code>redirect_from</code> may answer at: <code>check</code> says so in its advice, no repair is offered, and what answers there is the post&#39;s own old address, <code>former_slugs</code> (the wrong year in a link to <code>/posts/2025/a-post/</code> is fixed in the link). A link written relative to the post is rewritten to the address it means. A media directory or file no post references is moved to the trash the engine already uses -- <code>trash/&lt;year&gt;/&lt;slug&gt;/media/</code> -- and <code>./blog.sh restore &lt;slug&gt;</code> puts it back; the repair pass never deletes anything. A file whose name differs from the one on disk only in letter case or unicode form is not a leftover at all: the pass offers to write the name the directory actually uses into the post, and never touches the file. Text carrying HTML entities is decoded one post at a time, and the offer says how many places in that post change and shows the first -- it is one decision for the whole post, so a post with an imported <code>&amp;amp;</code> and a deliberate one is worth a look first. A post that changed after it was checked (edited in another window, or from the phone) is refused with a sentence rather than decoded on the strength of a scan it no longer matches.</p>
<p>Where the right answer is a matter of judgement -- two posts claiming one old address, an image the author has to look at, a link to something this archive never had, a slug two posts share across two years, or a target that is still a draft -- it says so and passes over. A second run proposes nothing, because the findings it repaired are gone -- except for text an import escaped more than once, which comes off one layer per decision; the run says so when a layer is left, and the next one offers it. Run <code>./blog.sh rebuild</code> afterwards to put the changes on the site.</p>
<p>The screen shows at most twenty findings of a kind and totals the rest in a &quot;...and N more&quot; line, which is right for reading and useless for acting on: a script that wants to add <code>redirect_from</code> for every dead link cannot work from a summary. <code>--json</code> prints all of them instead, each with the kind it is (<code>link_dead</code>, <code>media_stray</code>, <code>series_similar</code>...) and the data it is about -- the post&#39;s slug, the address, the file. The exit code is the same in both modes: 0 when the archive is sound, 1 when something in it is not.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Counting the archive</title>
  <link>https://manual.blogsh.app/posts/2026/counting-the-archive/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/counting-the-archive/</guid>
  <pubDate>Mon, 12 Oct 2026 10:50:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">./blog.sh stats           # a screen: posts by year and kind, words, tags, media, sources
./blog.sh stats --json    # the same figures, for whatever reads them next</code></pre>
<p>Reads the archive on disk -- no build, no network, no <code>env.sh</code> -- and comes back in seconds even on a large archive. What it says, and why each line is there:</p>
<ul><li><b>The archive</b>: posts split into published, drafts, scheduled and pages. The four add up to the total; a page that is also a draft counts once, as a page.</li><li><b>By year</b> and <b>What they are</b>: where the archive is thickest, and what it is made of (the dominant content type per post, the same one the <code>/type/</code> listings use).</li><li><b>Words</b>: total, mean and median. Both averages, deliberately -- an archive half made of imported tweets pulls the mean far above the middle, and only the pair says so.</li><li><b>Tags</b>, <b>Media</b> (files on disk, or what the posts ask for when the media directory is not here) and <b>Where it came from</b> -- the platforms the archive is actually made of, which is a different number from how many the importers support.</li></ul>
<p><code>--json</code> prints the same figures unlocalized and unrounded, so the numbers can go into a post, a cron job or a graph.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>On this day</title>
  <link>https://manual.blogsh.app/posts/2026/on-this-day/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/on-this-day/</guid>
  <pubDate>Mon, 12 Oct 2026 10:49:00 +0200</pubDate>
  <description><![CDATA[<pre class="code-block"><code class="language-bash">./blog.sh on-this-day                    # today: every post from earlier years, and what the card shows
./blog.sh on-this-day --date 12-24       # another day of this year
./blog.sh on-this-day --date 2030-12-24  # ...or of another year, ages counted from then
./blog.sh on-this-day --json             # the same, as data</code></pre>
<p>The same selection feeds a sidebar card, switched on by its name alone:</p>
<pre class="code-block"><code class="language-yaml">widgets:
  on_this_day:
    # heading: &quot;On this day&quot;    # optional -- the engine says it in every language</code></pre>
<p>(or in <code>./style.sh</code>, under the sidebar). The card reads the site&#39;s own archive, so there is no account to give and nothing to fetch. What it shows, and why each rule is there:</p>
<ul><li><b>Only the day itself.</b> A post from an earlier year on today&#39;s date, in the site&#39;s time zone. A day with nothing from an earlier year hides the card, heading and all -- there is no &quot;this week&quot; fallback, which on a small archive showed one post seven days running. A post written on 29 February is remembered on the 28th in the years without the day.</li><li><b>One row per year, five at most.</b> Round anniversaries (5, 10, 15, 20 years...) always get a row. The rest are spread along the years with today counted as taken, so the older a year is, the stronger its claim -- &quot;a year ago&quot; appears when there is too little older to fill the card, not every day.</li><li><b>A titled post stands for its year</b>, the longest first; an untitled one only when the year has nothing titled that day. Titles decide inside a year and never between years: on an archive grown from several platforms they mark where a post came from, not how good it is.</li><li><b>The day rotates.</b> One window for every five posts the day has, three at most, dividing the day evenly by the clock (0-12-24, 0-8-16-24). Anniversaries stay; the other rows go first to years the day has not shown yet, and a year shown again brings its next post. Under the rows, the rest of the day folds away in a list.</li></ul>
<p>Drafts, pages and unlisted posts never take part. The rule has no settings: it behaves the same on a hundred posts as on thirteen thousand, because what it depends on is how many years an archive spans and how many posts one year has on one day.</p>
<p>The card&#39;s file (<code>on-this-day.json</code>, in the root of every language&#39;s tree) is written by every build, and turned over after midnight by the sidebar cron below -- which is why a site with the card wants that cron even with no other widget. The cron chooses from a catalogue each build leaves beside the build cache (<code>.on-this-day.&lt;lang&gt;.json</code>), so it never needs to know what a translation is; a file from another day has no window for the page&#39;s clock and hides the card rather than show yesterday as today.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Letting a reader pass a post on</title>
  <link>https://manual.blogsh.app/posts/2026/letting-a-reader-pass-a-post-on/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/letting-a-reader-pass-a-post-on/</guid>
  <pubDate>Mon, 12 Oct 2026 10:48:00 +0200</pubDate>
  <description><![CDATA[<p>The old way to do this was a row of buttons for Facebook, Twitter and Pinterest, each with one address to send somebody to. The fediverse has no such address: the destination is the READER&#39;s instance, and a page has no way to know it.</p>
<p>So the block is a list you write, and it holds three different kinds of thing:</p>
<pre class="code-block"><code class="language-yaml">share:
  - mastodon
  - bluesky
  - email
  - copy
  - system</code></pre>
<p>Leave <code>share:</code> out and no block appears. The order here is the order they are drawn in.</p>
<p><code>bluesky</code>, <code>email</code>, <code>facebook</code>, <code>linkedin</code>, <code>threads</code> and <code>x</code> are plain links: one address each, nothing to ask, no script. <code>mastodon</code> asks the reader once which instance they are on -- in a row that opens under the buttons, in the site&#39;s own type -- and remembers the answer in their browser. Not in a dialog from the browser: those look like something the page did not make, browsers throttle them, and a box demanding the name of a server is the shape people are taught not to trust. And no third-party redirector, which would put an outside service on every post page and in the way of every share. <code>copy</code> puts the address on the clipboard and says that it did. <code>system</code> hands it to the operating system&#39;s own share sheet, which on a phone is Signal and WhatsApp and Telegram and whatever else is installed, so one control covers what a row of them could not.</p>
<p>Three of them need the browser to co-operate, and none is drawn where it cannot work -- a control that opens nothing is worse than no control. <code>mastodon</code> stays hidden until its script has run, <code>system</code> unless the browser has a share sheet, and <code>copy</code> unless there is a clipboard to write to. That last one has a condition worth knowing about: browsers only give a page the clipboard over <b>https</b>, so on a site served over plain <code>http://</code> the copy button is not drawn at all. If the whole block would be empty -- every target you named turned out to be one of these three, and none of them could run -- the block stays hidden too, rather than leaving a heading over nothing.</p>
<p>Pixelfed is not on the list. It has no share address to send anyone to -- the request for one was closed without it -- and a post there wants a photograph rather than a link.</p>
<p>What gets prefilled is the post&#39;s NAME and its address; the reader writes the part that is theirs. Nothing appears on a page, on a draft preview or on an unlisted post: there is nothing to pass on from a contact page, a draft has no address anybody else can open, and handing a reader a button that posts an unlisted post to Bluesky is the mistake the unlisted flag exists to prevent.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Rebuilding only what changed</title>
  <link>https://manual.blogsh.app/posts/2026/rebuilding-only-what-changed/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/rebuilding-only-what-changed/</guid>
  <pubDate>Mon, 12 Oct 2026 10:47:00 +0200</pubDate>
  <description><![CDATA[<p>A publish rebuilds the site, and until 1.6 that meant rebuilding all of it: every page rendered, and every file read back off disk to find out whether its bytes had moved. On an archive of a few thousand posts that is most of a minute, every time, for a change that touched a dozen files.</p>
<p>Since 1.6 the build writes down what it produced -- in <code>.build_cache.json</code> in the installation directory -- and the next build leaves alone the pages whose inputs have not moved. Nothing you do changes: <code>./blog.sh publish</code> and <code>./blog.sh rebuild</code> work exactly as before, only quicker. The summary at the end says how many pages were left alone, so you can see it working.</p>
<p>Measured on this project&#39;s own archive of 6,639 posts, on the server it lives on: a rebuild that changes nothing now costs a seventh of what it did, and an ordinary publish about two fifths. Backdating is the case it can do least about, and the measurement says so plainly -- a post dated into the early 2000s still costs four fifths of the old build. A post arriving in the middle of the archive shifts every listing page between the front page and where it lands, so those pages really did change and really do have to be built. Importing an archive, which is backdating several thousand times over, is a full build for the same reason.</p>
<p>The ratio is what travels; the seconds behind it are one machine&#39;s. These are our own measurements, on our own archive, on its own server. Yours will differ with your archive, your disk, and whatever else the machine is doing at the time.</p>
<p>The cache is an optimisation and never an authority. Anything it cannot vouch for is built the old way -- rendered, read back, compared. A page somebody deleted or damaged in <code>public.nosync/</code> by hand comes back on the next build, because the record is checked against what is actually on disk, and <code>public.nosync/</code> is still swept clean of anything the build does not produce on every build, cache or no cache. Editing a template, a locale or <code>site.yml</code> throws the whole cache away, since any of those can change every page; so does changing <code>SITE_BASE_URL</code>, and so does a change of timezone -- including the one that happens without you touching anything, when a system update rewrites the rules of the zone you publish in. A stylesheet is the exception worth knowing: pages link it rather than embed it, so editing one changes the stylesheet and nothing else -- the build skips every page it would have rebuilt and the deploy sends the one file. The palette in <code>site.yml</code> is not that case, since the stylesheet it generates and the theme colour in every page&#39;s head both come from it.</p>
<pre class="code-block"><code class="language-bash">./blog.sh rebuild --full        # build every page again, then deploy</code></pre>
<p>Reach for <code>rebuild --full</code> when you want the site rendered from scratch -- after restoring <code>public.nosync/</code> from a backup, say, or when you suspect what is on disk is not what the archive says it should be. It costs one slow build, and it still leaves a usable record behind for the build after it. (<code>ruby build/build_blog.rb --full</code> does the building half alone, if you want the site rebuilt without being deployed.)</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>A published picture is the archive&#39;s own file</title>
  <link>https://manual.blogsh.app/posts/2026/a-published-picture-is-the-archive-s-own-file/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/a-published-picture-is-the-archive-s-own-file/</guid>
  <pubDate>Mon, 12 Oct 2026 10:46:00 +0200</pubDate>
  <description><![CDATA[<p>Until 1.6 every picture existed twice: once in <code>media.nosync/</code>, and once as a copy of itself under <code>public.nosync/</code>. On this project&#39;s own installation that was 1.8 GB counted twice, and every import doubled again.</p>
<p>They are one file under two names now. Nothing about publishing changes, and neither does the archive&#39;s copy: deleting a picture from <code>public.nosync/</code> -- by hand, or by the sweep, or by a deploy with <code>--prune</code> -- drops that name and leaves the archive&#39;s. What DOES follow the link is the mode: making the published picture readable makes the original readable too, which is the direction that was wanted, because a picture the web server cannot read was the bug this makes impossible.</p>
<p>It saves disk and it does not save backups. Measured against this project&#39;s own: 200 files under two names take 101 MB on the disk and 201 MB in the backup, because the backup copies file by file and stores each name in full. Back up <code>media.nosync/</code> as you always did -- that is the archive&#39;s copy, and it is the one that matters.</p>]]></description>
  <category>operations</category>
</item>
<item>
  <title>Deploying</title>
  <link>https://manual.blogsh.app/posts/2026/deploying/</link>
  <guid isPermaLink="true">https://manual.blogsh.app/posts/2026/deploying/</guid>
  <pubDate>Mon, 12 Oct 2026 10:45:00 +0200</pubDate>
  <description><![CDATA[<p><code>./blog.sh rebuild</code> = build + deploy with <code>--prune</code> in one step; the publish/edit flows run it for you. The deploy script alone:</p>
<pre class="code-block"><code class="language-bash">./scripts/deploy-web.sh             # only new/changed files
./scripts/deploy-web.sh --dry-run   # print what would happen, touch nothing
./scripts/deploy-web.sh --prune     # also delete files the build no longer generates
./scripts/deploy-web.sh --force     # ignore the manifest, re-upload everything
./scripts/deploy-web.sh --only=A,B  # just the listed files</code></pre>
<p>Things worth knowing:</p>
<ul><li><b>The safety guards.</b> Four of them, all measuring this build against the last build a deploy <i>accepted</i> -- recorded in <code>.deploy_baseline.json</code> before the first byte goes out, so no upload failure can move it:</li></ul>
<div class="table-wrap"><table><thead><tr><th>Guard</th><th>Trips at</th><th>What it does</th></tr></thead><tbody><tr><td>File count dropped</td><td>&gt;20%, at least 8 files</td><td>stops</td></tr><tr><td>Total bytes dropped</td><td>&gt;50%, at least 25 MB</td><td>stops</td></tr><tr><td>File count grew</td><td>&gt;20%, at least 25 files</td><td>stops</td></tr><tr><td>Total bytes grew</td><td>&gt;50%</td><td>says so, continues</td></tr></tbody></table></div>
<p>A drop almost always means a broken build; the byte version catches what counts cannot -- the same pages, each nearly empty. Growth in bytes is only a notice, because adding a video is authoring, not a malfunction; a single file too big to host is caught separately and by name (below). If a swing is genuinely intended (bulk import, mass deletion), rerun with <code>--force</code>. An empty build is always refused.</p>
<p>The absolute floors keep the percentages usable on a small site, where two posts published at once would otherwise read as an explosion.</p>
<p>A change of the site&#39;s languages is not a swing. Adding one grows the build by a whole tree (<code>/en/</code>, every listing, tag and feed in it) and removing one shrinks it by as much, so the deploy says the languages changed, leaves those trees out of the comparison and holds the rest of the site to the limits above -- a broken build on the same day still stops. The files of a language the site no longer publishes are deleted from the target by that deploy, without <code>--prune</code>.</p>
<p>A drop also measures against the manifest when that is larger, since   every entry in it is a file that really did upload. Growth never does:   the manifest legitimately lags the build after a failed upload or on a   fresh target, and reading that lag as growth is exactly what used to   disable these guards. - <b>One file-size limit, everywhere.</b> A single file over 100 MB is   refused -- when the post is saved (so you can still shrink it) and   again before a deploy sends it. The same limit applies to every   backend so the site stays portable: the strictest supported target   (git pages) refuses anything larger. <code>--force</code> does not lift it, since   the target would refuse the file on every run. Files between 50 MB and   100 MB are named but allowed. A file already on the target from before   this limit existed is reported, not refused. - <b><code>--prune</code></b><b> is the only destructive flag.</b> Without it, files the   build stopped generating stay live on the target (the deploy log   counts these &quot;orphans&quot;). With the <code>git</code> backend every deploy is a   snapshot and prunes implicitly -- the log says &quot;(snapshot deploy)&quot;. - <b>The previous run&#39;s outcome is reported, not acted on.</b> A deploy that   failed or was interrupted says so at the top of the next one, and after   three unfinished runs in a row it says that too -- however high the   count, since stopping after N attempts would be its own dead end. - <b>Manifests are disposable.</b> <code>.deploy_manifest*.json</code> (one per   backend) records what the target already has. Deleting one is always   safe -- the next deploy re-uploads everything once and rebuilds it. The   guards are unaffected, because their reference lives elsewhere. - <b>...with one exception worth knowing.</b> <code>rsync</code>, <code>rclone</code> and <code>git</code>   diff the target themselves, so a lost manifest costs one full re-upload   and nothing else. For <code>sftp</code>, Surfer and a local directory the engine   never reads the far end, so the manifest is also the only record of what   stands on it: a file you delete at home <i>after</i> the manifest went missing   stays on the target, and no flag will find it again. This is why a deploy   says out loud that the manifest was written for another target instead of   quietly starting over -- and why the target&#39;s identity ignores the   switches that cannot move a connection (<code>-v</code>, <code>-q</code>, the order they are   written in), so reformatting a line in <code>env.sh</code> is not a new target.</p>
<ul><li><b>Switching backends</b> starts from a fresh manifest on purpose; the first deploy to a new target uploads the whole site. The baseline is <i>shared</i> across backends -- it describes the build, which is the same wherever it goes -- so switching targets no longer leaves the guards with nothing to compare against.</li></ul>
<h2 id="checking-the-guards-by-hand">Checking the guards by hand</h2>
<p><code>--dry-run</code> needs no target and writes nothing, which makes it the way to prove the guards still behave before trusting a release. Copy a build to a scratch directory, point <code>DEPLOY_TARGET_DIR</code> at a throwaway path with <code>DEPLOY_BACKEND=local</code>, and work through the cases that are easy to get wrong:</p>
<div class="table-wrap"><table><thead><tr><th>Set up</th><th><code>--dry-run</code> must</th></tr></thead><tbody><tr><td>Delete one post from a small site</td><td>pass -- the absolute floor covers it</td></tr><tr><td>Delete most of the build</td><td>stop, naming the accepted build it compared against</td></tr><tr><td>Publish two posts at once on a small site</td><td>pass</td></tr><tr><td>Duplicate the build</td><td>stop</td></tr><tr><td>Baseline intact, manifest truncated, build complete</td><td>pass -- this is recovery after a failed upload, and it is the case a naive fix breaks</td></tr><tr><td>Same, but the build is also broken</td><td>stop</td></tr><tr><td>Same file count, contents emptied past 25 MB</td><td>stop on bytes</td></tr><tr><td>Add one 60 MB file</td><td>pass, with a notice</td></tr><tr><td>Add one 120 MB file</td><td>stop, naming the file</td></tr><tr><td>Empty <code>public.nosync/</code></td><td>stop</td></tr><tr><td>Delete <code>.deploy_baseline.json</code></td><td>pass, with no stand-down notice -- the growth guard borrows the file count from the manifest</td></tr><tr><td>Delete the manifest too</td><td>pass, saying the growth guard stands down once</td></tr><tr><td>Any of the above</td><td>leave <code>.deploy_baseline.json</code> untouched -- a dry run is read-only</td></tr></tbody></table></div>
<p>Two things make this easier to reason about: the failure state is anything that leaves <code>last_run.outcome</code> in <code>.deploy_baseline.json</code> set to something other than <code>ok</code>, and a run under <code>--only</code> must never change that file at all (that is how the sidebar cron stays out of the way).</p>]]></description>
  <category>operations</category>
</item>

  </channel>
</rss>
