<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>philosophy – ./blog.sh</title>
    <link>https://blogsh.app/tag/philosophy/</link>
    <atom:link href="https://blogsh.app/tag/philosophy/rss.xml" rel="self" type="application/rss+xml" />
    <description>Posts tagged philosophy on Daniel Šnor&#39;s personal site.</description>
    <language>en</language>
    <lastBuildDate>Sat, 19 Sep 2026 09:49:45 +0200</lastBuildDate>
    <item>
  <title>Whoever loads last wins</title>
  <link>https://blogsh.app/posts/2026/whoever-loads-last-wins/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/whoever-loads-last-wins/</guid>
  <pubDate>Sat, 19 Sep 2026 09:49:45 +0200</pubDate>
  <description><![CDATA[<p>Two real sites wear a look this engine never shipped. sh.cynicky.blog is dressed as Ghost — a full-width lead card, a dotted frame, tiles instead of a plain list. arch-linux.cz is dressed as a Hugo theme, Blowfish specifically — a compact bar, a profile block, cards laid out like someone else&#39;s homepage entirely. Neither forked the engine. Neither edited a template. Both still take a plain <code>git pull</code>.</p>
<h2 id="one-stylesheet-loaded-last">One stylesheet, loaded last</h2>
<p><code>site.extra_css</code> in <code>config/site.yml</code> names a path, or a list of them, loaded after the engine&#39;s own <code>colors.css</code> and <code>site.css</code>. That&#39;s the entire mechanism — a skin is one file of yours that repaints what&#39;s already on the page, and the engine never writes to it, so an update can never conflict with it.</p>
<p>Local paths only: every page carries <code>style-src &#39;self&#39;</code>, and a stylesheet on another host would just be dropped by the browser with no error anyone would ever see — the build refuses a remote path out loud instead, before that silence has a chance to happen.</p>
<p>Whether that file belongs in git is a separate decision — commit it and a second machine already has your look, exclude it and a public repository never carries a design that wasn&#39;t meant to be public. The engine&#39;s own <code>.gitignore</code> doesn&#39;t decide this for you either way.</p>
<h2 id="the-rule-everything-else-follows-from">The rule everything else follows from</h2>
<p>Loading last means your rules win at equal specificity — including the rules the engine wrote for narrow screens, which is not the part anyone expects to be winning against.</p>
<p>sh.cynicky.blog&#39;s skin floats a table of contents beside the article and gave it a percentage width to match its own measure. On a desktop that looked right. Below 700px, the engine has its own reflow: the contents drop above the text and the width rule becomes <code>auto</code>, because there&#39;s no room beside anything at that size any more. The skin&#39;s percentage didn&#39;t know that and kept applying anyway — loading last means winning always, not just when it&#39;s convenient to. The article came out 155 pixels wide on a 390-pixel phone screen.</p>
<p>The fix wraps the override in the range where it&#39;s actually meant to apply:</p>
<pre class="code-block"><code class="language-css">@media (min-width: 861px) {
  .toc { width: 350px; }
}</code></pre>
<p>Below that width, the skin says nothing, and the engine&#39;s own reflow is left to do the one job it was already doing correctly.</p>
<h2 id="what-the-engine-leans-on-without-saying-so">What the engine leans on without saying so</h2>
<p>Two smaller traps came out of the other skin, arch-linux.cz&#39;s, and both share the same shape: a rule that looks purely decorative turns out to be load-bearing somewhere else.</p>
<p>Unsticking the engine&#39;s nav bar is a reasonable thing for a skin to want. On arch-linux.cz it also made the theme-switch button stop responding to clicks — not because the button broke, but because the bar sat in a CSS grid, where <code>z-index</code> takes effect without needing <code>position</code> at all, and the unstuck bar kept its higher layer sitting invisibly on top of the button. The site&#39;s own admin found it by actually clicking the switch; nothing in a screenshot would ever have shown it.</p>
<p>A second, smaller one: <code>max-width</code> on a flex item with <code>flex-basis: 100%</code> shrinks the item&#39;s hypothetical width rather than capping its contents — which is how an article body once ended up sitting beside its own meta line instead of below it. The cap belongs on the children, not the box around them.</p>
<h2 id="one-page-of-a-listing-not-the-whole-feed">One page of a listing, not the whole feed</h2>
<p>A stylesheet can&#39;t read an address, so the front page and <code>/page/2/</code> used to be indistinguishable to CSS — which mattered the moment either skin wanted a lead card on the first page only. Since 1.3.2 every listing&#39;s <code>&lt;body&gt;</code> says which kind it is:</p>
<pre class="code-block"><code class="language-css">.page-first .post-list-item:first-child { /* the lead card */ }
.page-cont  .archive-note { /* only on the continuations */ }</code></pre>
<p>Two classes rather than one, on purpose — a single mark on the continuations would mean writing the first page&#39;s look unconditionally and then undoing it property by property, and a rule that undoes another rule is the kind a later edit quietly stops undoing.</p>
<figure><img src="https://blogsh.app/posts/2026/whoever-loads-last-wins/01.png" width="1280" height="1100" alt="sh.cynicky.blog" loading="lazy" decoding="async"></figure>
<p>That&#39;s sh.cynicky.blog above — full-width lead card, dotted frame, tiles instead of a plain list, and not one edited engine file behind any of it. Its search stays a field in the engine&#39;s own bar rather than the modal Ghost itself would give it, because rebuilding it as one would mean owning behaviour the engine never promised. Neither skin, this one or arch-linux.cz&#39;s, ever tried for a pixel-perfect copy of what it imitates. Fidelity was never the goal. Belonging to the family was.</p>
<p>A skin stops being the cheap tool the moment it needs markup the engine doesn&#39;t build — at that point editing the template is the honest choice, not a stylesheet fighting to fake one. Short of that line, though, none of this needed a theme system: no hooks, no registry, no hierarchy of overrides to keep straight. One fact carries the whole feature. Your file loads last.</p>
<p>—</p>
<p>P.S. — sh.cynicky.blog isn&#39;t where cynicky.blog itself lives. The real site is still on Ghost, updated most days; sh.cynicky.blog is the same look, kept as a working installation of its own. Its author looked at a real migration and chose to stay where he already was — worth saying plainly, rather than letting a domain name imply otherwise.</p>]]></description>
  <category>appearance</category><category>philosophy</category>
</item>
<item>
  <title>The build doesn&#39;t know where it&#39;s going</title>
  <link>https://blogsh.app/posts/2026/the-build-doesn-t-know-where-it-s-going/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-build-doesn-t-know-where-it-s-going/</guid>
  <pubDate>Sat, 12 Sep 2026 19:26:30 +0200</pubDate>
  <description><![CDATA[<p>Somebody builds a whole site on a laptop, imports a decade of old posts into it, previews every page — and none of it has gone anywhere yet. Nothing in <code>env.sh</code> says where, and nothing else is waiting on that: the build runs, the archive checks out, every page renders at <code>localhost</code>. The build doesn&#39;t know where the site is supposed to end up, and it doesn&#39;t need to.</p>
<p>The same build can start on a laptop, move to a rented VPS a year later, and land on a Cloudron instance after that. Nothing in the content pipeline changes between any of those moves. What changes is the answer to one question — where should the files go — and six things can answer it.</p>
<h2 id="what-decides-it">What decides it</h2>
<p><code>DEPLOY_BACKEND</code> in <code>env.sh</code> names the target; the values under it depend on which one. Everything past that line is identical across all six. Each backend keeps its own manifest — the SHA-256, size and modification time of every file it has already uploaded — so a deploy only ever sends what changed since the last one. <code>deploy-web.sh --dry-run</code> reads that manifest without touching anything.</p>
<p>Here&#39;s what that prints, run just now against this site&#39;s own backend:</p>
<pre class="code-block"><code>$ ./scripts/deploy-web.sh --dry-run
== deploy-web.sh ==
Mode: preview (dry-run) -- nothing is actually uploaded.

Deploy web -&gt; Surfer: https://blogsh.app  [DRY-RUN]
  Files selected: 194, 33.3 MB in the build, new or changed: 0, unchanged (skipped): 194</code></pre>
<p>Nothing had changed since the last real deploy, so the manifest says so and stops there — the same sentence a <code>git</code> or <code>rclone</code> target would print, with a different line above it saying which one answered.</p>
<p>The manifest is per backend, though, so point the same build at a different target for the first time and that first deploy uploads everything — moving is one full upload, not a redo of anything the build did.</p>
<h2 id="the-strictest-one-sets-the-rule">The strictest one sets the rule</h2>
<p>A single file over 100 MB is refused at save time and again at deploy time, and the limit is the same number on all six backends — not because every target shares that ceiling, but because the engine picks the strictest one and holds everyone to it. A site built to run on <code>rsync</code> stays deployable on <code>sftp</code> without anyone finding out the hard way which backend was the forgiving one.</p>
<p>That&#39;s the tell that this isn&#39;t six uploaders sitting next to each other: it&#39;s one deploy model — one manifest shape, one safety guard, one file-size limit — with six ways to reach the far end. The backends differ in how the bytes travel; what&#39;s allowed to travel isn&#39;t theirs to decide.</p>
<h2 id="the-six">The six</h2>
<p><b>surfer</b> is the default — set <code>SURFER_URL</code> and <code>SURFER_TOKEN</code> and nothing else needs saying. It&#39;s Cloudron Surfer, the host this project itself runs on.</p>
<p><b>local</b> writes straight into a directory, for a docroot behind your own nginx or Caddy. HTTPS is the web server&#39;s job either way; the engine&#39;s CSP arrives as a meta tag, so there&#39;s nothing to configure on that front even without one.</p>
<p><b>rsync</b> goes to any SSH host with rsync installed — the most universal of the six, since that describes most VPSes and a good share of shared hosting too.</p>
<p><b>git</b> pushes to GitHub, GitLab or Codeberg Pages, force-pushing the whole build as a single commit on every deploy. A custom domain needs <code>GIT_PAGES_CNAME</code> set, because the host keeps that address in a file living inside the very branch the snapshot overwrites.</p>
<p><b>rclone</b> reaches S3, R2, B2, WebDAV and whatever else <code>rclone config</code> knows how to talk to, with the bucket&#39;s own credentials kept in rclone&#39;s config rather than in <code>env.sh</code>.</p>
<p><b>sftp</b> is for the hosts with neither rsync nor git: plain SSH, batch-mode <code>sftp</code>, one connection per deploy. Key auth has to be set up first — a batch job has no terminal to answer a password prompt, and the connection fails rather than waiting for one that isn&#39;t coming.</p>
<p>Which of those six a site runs on is a single line in <code>env.sh</code>, picked once and rarely revisited — because the harder half of the problem was never the backend. It was making sure it wouldn&#39;t matter which one got picked.</p>]]></description>
  <category>deploy</category><category>philosophy</category>
</item>
<item>
  <title>Written on a train</title>
  <link>https://blogsh.app/posts/2026/written-on-a-train/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/written-on-a-train/</guid>
  <pubDate>Fri, 04 Sep 2026 10:51:00 +0200</pubDate>
  <description><![CDATA[<p>A reader who was thinking about moving to this engine wrote to say he would stay where he was. He manages his blog from a phone, on the road, and a blog that can only be worked from a terminal was no use to him however fast it built. He was right.</p>
<p>1.6 answers him with two things, and with one thing it refuses to do.</p>
<h2 id="a-page-on-the-blog-itself">A page on the blog itself</h2>
<p>Set <code>write: true</code> and the build publishes a small editor at <code>/write/</code>: a title, the text, tags, photographs and video, each picture with its description. It wears the blog it writes to — its name, its palette, its language — and it offers the blog&#39;s own tags as you type, the ones used in the last twelve months first, so a tag is tapped rather than spelt a second way. A preview shows the post in the blog&#39;s own stylesheets. A row of marks above the text wraps what is selected and comes off again on a second tap. Above the send button, a Draft | Publish switch.</p>
<p>Everything is kept in the browser between visits — the text in local storage, the pictures&#39; bytes in IndexedDB, because five megabytes is all local storage gets and one phone video is more than that. When somebody interrupts you on the platform, nothing is lost.</p>
<p>The page is off by default, marked <code>noindex</code>, and holds no secret. It is served from the blog itself, so the same <code>git pull</code> moves both ends of the protocol and they cannot drift apart.</p>
<h2 id="a-key-and-nothing-new-on-the-network">A key, and nothing new on the network</h2>
<p>Sending is two iOS shortcuts. One takes the files from the share sheet, the other opens the connection, because the shortcut that receives files may not open one. The connection is the SSH the server already has, and the key lives in the shortcut, on the phone. On the server, that key runs one forced command: <code>scripts/receive.sh</code>, which takes the whole post over one connection — pictures first, the markdown last — checks every name and decodes every body before it writes anything, and hands the markdown to <code>add</code>.</p>
<p>Nothing new listens. There is no login, no session, no token in a form. A delivery over the ceiling hears <code>too_large</code> instead of a closed channel; one that goes quiet is dropped. The server&#39;s answer comes back to the page in the address bar, in the reader&#39;s language, and the draft is cleared from the phone once the server has the post.</p>
<h2 id="what-it-refuses-to-do">What it refuses to do</h2>
<p>It does not edit old posts. It does not delete, rename, schedule, back up or show statistics. It is one page on which a post can be written and sent, and nothing more.</p>
<p>That is the design, not the shortfall. Editing old posts from a phone would need what this engine has never had — accounts, sessions, a login — and the moment those exist, so does everything that goes wrong with them. A page that can only add, over a key that can only run one command, has nothing to defend.</p>
<h2 id="what-it-costs">What it costs</h2>
<p>The first time is not two minutes: a key on the server, two shortcuts on the phone, one walk through the whole thing. Half an hour when nothing snags. The second time is opening a page and writing.</p>
<p>The page shipped in 1.6, two days of work after the message that asked for it. He was right, and the right answer to being told so is two days of work rather than a paragraph about why it has to be this way.</p>]]></description>
  <category>authoring</category><category>philosophy</category>
</item>
<item>
  <title>An archive you cannot see is not empty</title>
  <link>https://blogsh.app/posts/2026/an-archive-you-cannot-see-is-not-empty/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/an-archive-you-cannot-see-is-not-empty/</guid>
  <pubDate>Tue, 01 Sep 2026 16:22:00 +0200</pubDate>
  <description><![CDATA[<p>This site&#39;s approved comments and its post counters are two small JSON files, refreshed by a cron job every half hour and uploaded with the rest of the site.</p>
<p>Through 1.4, a tick that ran while the archive was not there would narrow both files to nothing and upload them. An absent or empty content directory answers with an empty list and no error, and an empty list reads exactly like &quot;every announced post was deleted&quot;. The live site lost every approved comment and every counter at once. Exit 0, nothing said, nothing mailed.</p>
<p>The archive lives on a synced volume. A volume that has not mounted yet, or a working copy whose files are still arriving, is not an unusual Tuesday.</p>
<h2 id="not-being-able-to-tell-is-a-reason-to-keep-everything">Not being able to tell is a reason to keep everything</h2>
<p>That sentence is the fix, and it now runs in two tiers.</p>
<p>If the content directory is not there — or if the archive reads as empty while the previous files held anything at all — nothing is narrowed, and the run says why.</p>
<p>And if only part of the archive is unreadable, a post file that will not parse this minute, a half-written save, a cloud copy still arriving, nothing is narrowed either. Otherwise that gap would be read as a post that was deleted, and its approved discussion deleted along with it. Only a run that could read the whole archive is allowed to decide that something is gone.</p>
<h2 id="the-same-mistake-one-job-over">The same mistake, one job over</h2>
<p>The scheduled-publish cron had a sibling of this. It took a snapshot of the due posts when it started and wrote that snapshot back when it finished, so everything that happened in between was overwritten. An edit made while it worked was silently replaced with the older text. A post deleted in the meantime was recreated from the snapshot, published, and announced to a timeline that cannot be recalled.</p>
<p>Each post is re-read at the moment it is written now.</p>
<h2 id="why-these-two-are-worth-a-post">Why these two are worth a post</h2>
<p>Neither of them is a crash. Both jobs ran, both exited 0, both reported success, and one of them was announcing things to the world while it did.</p>
<p>That is the failure worth being afraid of in a tool that runs unattended: not the job that stops, but the job that carries on confidently with a wrong idea of what your archive contains. A crash you find out about. This kind you find out about when somebody asks where their comment went.</p>]]></description>
  <category>archive</category><category>philosophy</category>
</item>
<item>
  <title>Posts nobody meant to publish</title>
  <link>https://blogsh.app/posts/2026/posts-nobody-meant-to-publish/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/posts-nobody-meant-to-publish/</guid>
  <pubDate>Tue, 01 Sep 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>An importer&#39;s worst failure is not losing a post. It is publishing one.</p>
<p>1.5 went through all eleven import adapters and the machinery underneath them looking for exactly that, and found it three times over, in three different files, made the same way each time.</p>
<h2 id="an-archive-is-the-whole-account">An archive is the whole account</h2>
<p>A Mastodon export is not your public timeline. The outbox holds your followers-only posts and your direct messages beside the public ones — and every standalone toot in it was being written as published. On the archive this was measured against that is 141 posts of 2,548, of which 132 are direct messages, each given its own page, its own sitemap entry and its own item in the feed. Nothing in the run&#39;s summary mentioned it.</p>
<p>The scope was in the export all along, exactly where ActivityPub puts it. Public in <code>to</code> is public. Public in <code>cc</code> is unlisted — a real address, out of the listings. Followers-only and direct become drafts: that keeps the author&#39;s words in the archive and off the site, which is the only reading of &quot;not public&quot; that cannot publish something by accident.</p>
<h2 id="gated-at-serve-time-is-not-gated">Gated at serve time is not gated</h2>
<p>Ghost ships the full body of every members-only and paid post in its export, because a Ghost site decides who you are when it serves the page. Read by anything that does not make that check, every word anybody paid for is plain text sitting in a file.</p>
<p>They arrived published, untagged and uncounted. They now arrive as drafts, tagged, and counted in the summary — the reversible half of the decision, and a number the person running the import can act on.</p>
<h2 id="a-comment-on-the-wrong-line">A comment on the wrong line</h2>
<p>Hugo&#39;s TOML front matter says <code>draft = true</code>. Written as <code>draft = true # nekdy pozdeji</code> — a note to yourself, on precisely the line where a person writes one — the value scanner took the comment as part of the value. That made the flag a truthy string rather than the boolean it looks like, and a truthy string is not <code>true</code>, so the post imported as published.</p>
<p>Somebody&#39;s unfinished writing went onto the open web because they annotated the flag that was supposed to hold it back.</p>
<h2 id="the-rule-underneath">The rule underneath</h2>
<p>All three are one mistake wearing different clothes: the importer treated anything it could not classify as public. The rule runs the other way now. Not being able to tell whether something is publishable is a reason not to publish it — and to say so in the summary, with a count.</p>
<p>An import you have to audit afterwards is one you cannot trust. The point of a number in the summary is that you should not have to go looking.</p>]]></description>
  <category>migration</category><category>philosophy</category>
</item>
<item>
  <title>Tested like an enemy</title>
  <link>https://blogsh.app/posts/2026/tested-like-an-enemy/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/tested-like-an-enemy/</guid>
  <pubDate>Fri, 28 Aug 2026 16:52:00 +0200</pubDate>
  <description><![CDATA[<p>1.4 went through four adversarial audit rounds before the tag: fleets of independent reviewers over real archives — real exports, a real 4400-post site, real deploy targets — each told to break the engine rather than confirm it. The findings numbered over a hundred. Every one was reproduced before it was fixed, and every fix is pinned by a test that demonstrably fails on the code it fixed.</p>
<h2 id="the-pattern-that-kept-repeating">The pattern that kept repeating</h2>
<p>The uncomfortable lesson: each round&#39;s fixes were where the next round found its bugs. A fix for a comment-parsing rule deleted a hundred lines of somebody&#39;s config; a fix for that hid a widget&#39;s answers; the recovery advice written to save posts could destroy one. Four times in a row, the newest code was the most dangerous code in the tree — which is exactly why the last round audited nothing but the fixes themselves. It caught three regressions of mine before any user could.</p>
<h2 id="eighteen-files">Eighteen files</h2>
<p>The proof I trust most is the quietest one. The whole release, rebuilt over a real 4400-post archive and compared byte for byte against 1.3.2, changes eighteen files: one stylesheet, two scripts, and one line in each draft preview. Published posts do not move by a byte. A release can rework the machinery this much and still owe its archive that kind of stillness — that is the contract, and now there is a diff that shows it being kept.</p>
<h2 id="what-you-get-out-of-it">What you get out of it</h2>
<p>Not a promise of zero bugs — nobody honest sells that. The promise is narrower and better: the failure modes that stay are the loud kind. <code>check</code> refuses what the build refuses. A crash cannot cost a post. A deploy pointed somewhere new uploads everything rather than trusting old records. When the next bug arrives, it should have to announce itself.</p>]]></description>
  <category>philosophy</category>
</item>
<item>
  <title>What a crash may cost</title>
  <link>https://blogsh.app/posts/2026/what-a-crash-may-cost/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/what-a-crash-may-cost/</guid>
  <pubDate>Fri, 28 Aug 2026 10:51:00 +0200</pubDate>
  <description><![CDATA[<p>The queue can now swap two scheduled posts that share a slug across two years — with their media and their edit history riding along, because those are keyed by year and slug exactly like the post file. That sentence is short; making it true was most of a release cycle. The interesting part is not the swap. It is what happens when the power goes out in the middle of one.</p>
<h2 id="the-budget-is-zero">The budget is zero</h2>
<p>A move is several renames, and a crash can land between any two of them. The rule 1.4 holds to: whatever the moment, a crash may cost you the <i>move</i> — never a post. Files step aside under parking names before anything writes; a failed write puts everything back, byte for byte; a hard kill that strands a parked file leaves it where <code>check</code> finds it and says truthfully what it is.</p>
<h2 id="the-advice-is-part-of-the-system">The advice is part of the system</h2>
<p>That last piece turned out to be the hardest. An early version of check&#39;s advice said &quot;this is a stale copy — delete it&quot;, from nothing more than a file existing at the parked file&#39;s old name. But parking only happens when two posts share a slug, so the file standing there is by construction a <i>different</i> post — and after the right crash, the parked file is the only copy of yours there is. The advice now rests on the parked post&#39;s own identity: proven-stale names where the live copy sits; unproven says plainly that this may be the only copy, compare before you touch it, and never delete.</p>
<p>A recovery instruction that can destroy what it recovers is a bug like any other. It just took an audit aimed at our own fixes to see it.</p>]]></description>
  <category>archive</category><category>philosophy</category>
</item>
<item>
  <title>My fixtures agreed with me</title>
  <link>https://blogsh.app/posts/2026/my-fixtures-agreed-with-me/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/my-fixtures-agreed-with-me/</guid>
  <pubDate>Mon, 24 Aug 2026 10:11:00 +0200</pubDate>
  <description><![CDATA[<p>The importers had a good test suite. Every source had fixtures, the fixtures were parsed, the results were asserted, and everything passed.</p>
<p>Then I ran the importers over three archives I had not made: a Ghost export of 118 posts and 419 images, a Hugo tree of 77 files, and a WordPress WXR of 194 items. Alongside them, a fourth that is entirely mine — this engine&#39;s own export, read straight back in — because that one is not asking whether the code survives a stranger, it is asking whether the two halves of a round trip still agree.</p>
<p>The three foreign ones were the point, though. Fixtures had agreed with the code because both ends of them are mine.</p>
<h2 id="the-picture-nobody-failed-to-download">The picture nobody failed to download</h2>
<p><code>{{&lt; figure &gt;}}</code> is how Hugo writes a picture, and the Liquid stripper ate it as a Liquid tag. Both of the ones in that archive pointed at files lying right next to the article in its page bundle: nothing to download, nothing that could fail, and they were gone without a line in the summary — because nobody had tried to fetch them, so nobody had failed.</p>
<p>That is the shape of every finding in this pass. Not crashes. Silence.</p>
<h2 id="the-promise-that-held-for-three-per-cent">The promise that held for three per cent</h2>
<p><i>Images come from the tree itself — no network.</i> True of a Jekyll site, and repeated as a general promise about markdown trees in four places: the docs, the README&#39;s comparison table, the importer&#39;s header, and the comment above the adapter.</p>
<p>Of 79 image references in that real Hugo archive, 72 were absolute URLs pointing back at the WordPress the site had been migrated from years earlier. The import made 72 requests, 66 answered 404, and 20 of the 74 posts arrived with no pictures at all.</p>
<p>The summary reported all of that honestly. The documentation had told the reader there was nothing to report, so there was no reason to read it. All four places now say which half needs the network, give the number from that archive, and tell you to import while the old host still answers.</p>
<h2 id="a-fifth-of-a-site-with-urls-in-its-headings">A fifth of a site with URLs in its headings</h2>
<p><code>[](url)</code> is what a WordPress-to-Markdown conversion leaves behind where a heading anchor used to be. The parser required at least one character of label, so it matched nothing and printed the raw markdown to the reader: 153 heading blocks across 14 of those 74 posts, square brackets and a full URL in the heading, five of them welded onto the end of the actual words. The table of contents linked to anchors named after github.com.</p>
<h2 id="and-a-few-more">And a few more</h2>
<p>A video uploaded to Ghost was lost and three lines of its player were published in its place. A percent-escaped filename was slugified character by character, minting permanent addresses beginning <code>ef-bf-bc</code>. An article that showed markdown inside a code block had its example eaten — and chasing that turned up something worse underneath: a paragraph ending in a picture came back a newline short, so the fence under it stuck to the text and every fence from there down was read inside-out, code as prose and prose as code. On that archive, 30 of its 72 images each did that to everything below them.</p>
<p>What the HTML converter has to drop — players, iframes, forms — was counted by exactly one of the eleven adapters that use it and thrown away by the other ten, so a WordPress import named its losses and a Jekyll import of the same body said &quot;Done.&quot;</p>
<h2 id="the-lesson-is-cheap-and-i-keep-re-learning-it">The lesson is cheap and I keep re-learning it</h2>
<p>A fixture I wrote encodes what I believed when I wrote it. A stranger&#39;s archive encodes what actually happened to their blog over fifteen years, on platforms that changed under them, exported by a tool that had its own bad day.</p>
<p>Only one of those two can tell me I am wrong.</p>]]></description>
  <category>migration</category><category>philosophy</category>
</item>
<item>
  <title>The way out ships with it</title>
  <link>https://blogsh.app/posts/2026/the-way-out-ships-with-it/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-way-out-ships-with-it/</guid>
  <pubDate>Wed, 19 Aug 2026 10:31:00 +0200</pubDate>
  <description><![CDATA[<p>An engine that cannot hand your writing back is a hosting service with extra steps. The importers have always pointed one way — twenty-two sources, everything coming home. <code>./blog.sh export</code> is the other direction, and it should have been there first.</p>
<h2 id="what-comes-out">What comes out</h2>
<p>The whole archive as a tree of markdown files with YAML front matter, in the layout Jekyll reads: <code>_posts/2026-05-01-slug.md</code>, <code>_drafts/</code>, pages at the root, media under <code>assets/&lt;year&gt;/&lt;slug&gt;/</code>. Old addresses travel as <code>redirect_from</code>, in the shape the <code>jekyll-redirect-from</code> plugin already understands, merging both kinds — where the post lived on the platform it came from, and where it lived here before a rename. Point a static generator at the folder and it builds.</p>
<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>This site, counted rather than written:</p>
<pre class="code-block"><code>Would write to /tmp/bsexport: 43 post(s), 0 draft(s), 0 page(s), and 24
media file(s) (27.6 MB).
9 block(s) markdown has no syntax for were written as HTML: link (6),
audio (1), file (1), video (1). Any engine that passes HTML through renders them,
and this engine reads them back whole -- each one carries its own
definition in a comment above it, which everybody else ignores.

The layout is Jekyll&#39;s: _posts/, _drafts/, pages at the root, media
under assets/. Hugo, Eleventy and Astro read it with a line of config;
./import.sh reads it as it stands.</code></pre>
<h2 id="what-markdown-has-no-word-for">What markdown has no word for</h2>
<p>Plenty. An attachment with a label and a size, a video with a poster, a gallery, an embed — markdown has no syntax for any of it. Those blocks go out as HTML with their definition in a comment above them:</p>
<pre class="code-block"><code class="language-html">&lt;!-- blogsh:block {&quot;type&quot;:&quot;file&quot;,&quot;label&quot;:&quot;...&quot;} --&gt;</code></pre>
<p>Any other tool sees ordinary HTML and renders it. <code>./import.sh</code> reads the comment and gets the block back whole. That is the difference between an export that lets you leave and one that lets you move: export from one machine, import on another, and the installation arrives with its attachments still attachments.</p>
<h2 id="it-reads-the-archive-not-the-site">It reads the archive, not the site</h2>
<p>No build, no network, no <code>env.sh</code>. It walks <code>content.nosync/</code> and writes. Which means it works on the day the config stops parsing, on a server you are being evicted from, on a clone you found in a backup — the moments when needing an export is not hypothetical.</p>
<p>The one guard: a directory that already has something in it is refused until you repeat the command with <code>--force</code>, which then writes alongside what is there. An export never deletes anything, at either end. A directory holding only dotfiles counts as holding something — a freshly cloned repository used to read as empty and get written into without the <code>--force</code> the guard exists to require.</p>
<h2 id="why-it-is-in-the-engine-and-not-in-a-script">Why it is in the engine and not in a script</h2>
<p>Because a tool nobody maintains is a promise nobody keeps. The round trip — export, re-import, compare field by field — is in the test suite, and it earned its place there immediately: the first honest run found that an attachment came home as a paragraph with a link in it. Label gone, size gone, block type gone, file orphaned in the tree. Nothing had reported a loss, because both ends of the fixture were mine.</p>
<p>That is the argument for shipping the exit with the engine. A way out nobody ever walks is not a way out — and walking this one, field by field against what went in, is the only reason that defect was ever found.</p>]]></description>
  <category>archive</category><category>philosophy</category>
</item>
<item>
  <title>A blog engine that became an archive</title>
  <link>https://blogsh.app/posts/2026/a-blog-engine-that-became-an-archive/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/a-blog-engine-that-became-an-archive/</guid>
  <pubDate>Tue, 11 Aug 2026 10:21:00 +0200</pubDate>
  <description><![CDATA[<p>The first post on this site is about a photo I put on Instagram in 2012. The link still works. The photo doesn&#39;t.</p>
<p>That post was about one archive — mine — and one rule: media comes home. This one is about what happened when that rule was applied to every platform I could find an export for.</p>
<h2 id="the-roster-is-closed">The roster is closed</h2>
<p>Twenty-two sources import now, in three groups.</p>
<p><b>Social networks:</b> Bluesky, Facebook, Instagram, Mastodon, Pixelfed, Threads, Twitter/X.</p>
<p><b>Blogs and publishing platforms:</b> beehiiv, Blogger, Ghost, Jekyll/Hugo and any markdown folder, LiveJournal, Medium, Movable Type and TypePad, podcasts (any feed with audio), Squarespace, Substack, Tumblr, Wix, WordPress and any RSS or Atom feed.</p>
<p><b>And dead sites:</b> the Wayback Machine, for blogs whose platform no longer exists at all.</p>
<figure><img src="https://blogsh.app/posts/2026/a-blog-engine-that-became-an-archive/01.png" width="1024" height="720" alt="The import wizard: three groups, with the blog platforms expanded" loading="lazy" decoding="async"><figcaption>Twenty-two sources behind twenty-one choices: WordPress and a plain feed share one</figcaption></figure>
<p>That last group is the one I did not expect to build, and it is the reason this post exists.</p>
<h2 id="what-changed-isn-t-the-count">What changed isn&#39;t the count</h2>
<p>An engine with eight importers is a blog engine that can read your old blog. An engine with twenty-two, covering every social network you are likely to have posted to, is something else: it is a place to put the things you wrote on other people&#39;s websites.</p>
<p>So here is the claim, plainly. <b>This is a blog. You can also use it as an archive of your social networks.</b> Point it at an export, and what comes out the other side is a folder of JSON files and the actual image and video and audio files, on your disk, with the origin recorded as a tag and every post reachable at a permanent address.</p>
<p>You do not have to put any of it on the web. The import writes the files and then asks whether to rebuild and deploy; answer no and the archive stays on your disk. An archive that only you read is still an archive.</p>
<h2 id="why-the-exports-and-not-the-apis">Why the exports and not the APIs</h2>
<p>Every importer here reads a file you asked the platform for, not a live API. That was a decision, and it holds up better every year: an export is a legal right in most of the world, it does not expire, it does not get repriced, and it does not need a developer account that a policy change can revoke. The three sources that talk to a network at all — Bluesky, Tumblr, LiveJournal — do it because those networks hand a public archive to anyone who asks, no token required.</p>
<p>The Facebook importer is built on Meta&#39;s own &quot;Download Your Information&quot;. The Threads one reads the same export. Instagram, Mastodon, Pixelfed, Twitter, all the same shape: a file, not a permission.</p>
<h2 id="the-uncomfortable-arithmetic">The uncomfortable arithmetic</h2>
<p>On the reference Facebook export used to build that importer, <b>95 % of the posts were mirrored in from somewhere else</b> — Twitter, Posterous, the whole crosspost era. They are recognized and skipped, with a count, because those platforms&#39; own exports carry the originals and importing both would double your archive with worse copies.</p>
<p>That number stayed with me. Nineteen out of twenty things on that account were not really written there. Whatever you think you have on any one platform, a good deal of it is a copy of something you put somewhere else — and the somewhere else has usually closed.</p>
<h2 id="the-point-again">The point, again</h2>
<p>An archive is only yours if the files are yours. Fourteen years was long enough to watch a few landlords disappear; twenty-two importers is what it took to stop renting.</p>
<p>Every source, and what each export does and doesn&#39;t contain, is in <a href="https://github.com/DanielSnor/blog.sh/blob/main/docs/importing.md">docs/importing.md</a>.</p>]]></description>
  <category>migration</category><category>philosophy</category><category>archive</category>
</item>
<item>
  <title>The guard that switched itself off</title>
  <link>https://blogsh.app/posts/2026/the-guard-that-switched-itself-off/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/the-guard-that-switched-itself-off/</guid>
  <pubDate>Thu, 06 Aug 2026 20:47:38 +0200</pubDate>
  <description><![CDATA[<p>This site&#39;s deploy has two guards. If a build suddenly has far fewer files than what&#39;s live, or far fewer bytes, the deploy stops. They exist because a broken build looks exactly like a deliberate one to <code>rsync --delete</code>, and a static site is only ever one confident sync away from being erased.</p>
<p>In 1.0 they could turn themselves off. Permanently. Silently. Here is how, because the shape of this mistake is more useful than the fix.</p>
<h2 id="the-reference-was-the-wrong-thing">The reference was the wrong thing</h2>
<p>The guards compared the new build against the deploy manifest — a record of what&#39;s on the target. Reasonable, until an upload fails. A failed upload leaves the manifest out of true, and a guard measuring against a record it knows is wrong would fire on every subsequent run.</p>
<p>So there was a marker: after a failed run, stand the guards down until a clean run comes along and restores the reference.</p>
<p>You can see it already. When the failure is permanent — a file the host keeps refusing, expired credentials, a target that no longer exists — no clean run ever comes. The marker never lifts. The guards are off, and nothing says so. A build collapsing from 7,500 files to a handful would have been mirrored faithfully, <code>--prune</code> included.</p>
<h2 id="the-fix-was-to-stop-measuring-the-target">The fix was to stop measuring the target</h2>
<p>They now compare the build against the last build that was accepted, recorded before the first byte moves. That number doesn&#39;t care whether the upload then succeeded, failed, or died halfway — so there&#39;s no longer anything to stand down, and no marker to get stuck.</p>
<p>The reference stopped being &quot;what&#39;s out there&quot; and became &quot;what I last agreed to&quot;. Nothing else had to change.</p>
<h2 id="three-more-things-came-out-of-the-same-review">Three more things came out of the same review</h2>
<p><b>They also fired when they shouldn&#39;t.</b> Twenty per cent of a 32-file build is six files, so publishing two posts at once could abort a deploy — inside a flow <code>./blog.sh</code> runs for you, which has no way to pass <code>--force</code>. The percentages carry absolute floors now.</p>
<p><b>Bytes are guarded in both directions.</b> The same file count with every page nearly empty used to be invisible. A byte drop stops the deploy; a byte increase only mentions itself, because attaching media is authoring, not a fault.</p>
<p><b>An empty build is refused outright.</b> With an empty manifest, it used to sail through every check that existed.</p>
<h2 id="why-write-this-up">Why write this up</h2>
<p>Because &quot;we found a bug&quot; is worth less than &quot;here is the reasoning that produced it&quot;. The bug wasn&#39;t sloppiness. It was a patch that solved the problem in front of it and created a worse one behind it, and it survived because the failure mode was silence.</p>
<p>The lesson I&#39;m keeping: a safety mechanism that can be disabled by the thing it&#39;s protecting against isn&#39;t a safety mechanism. It&#39;s a suggestion.</p>]]></description>
  <category>deploy</category><category>philosophy</category>
</item>
<item>
  <title>Fourteen years, and every image came home</title>
  <link>https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:35 +0000</pubDate>
  <description><![CDATA[<p>In 2012 I posted a photo to Instagram. The link still works. The photo doesn&#39;t.</p>
<p>If you kept a blog anywhere between 2010 and now, you know the drill. The platform got acquired, or pivoted, or &quot;sunset some legacy infrastructure&quot;, and the images you embedded from their CDN quietly turned into grey rectangles. My personal archive goes back fourteen years across Tumblr, Twitter, Mastodon and a couple of other places — and a depressing number of its images now live at addresses that answer with nothing at all.</p>
<p>Here&#39;s what I mean, from my own archive. On February 22, 2012, I published a blog post called <i>#53: Messengers of Spring?</i> and embedded its photo straight from Instagram&#39;s CDN, the way everyone did back then. This is that link today:</p>
<figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/01.png" width="1280" height="720" alt="Browser error page saying the server IP address of distilleryimage1.instagram.com could not be found" loading="lazy" decoding="async"><figcaption>distilleryimage1.instagram.com, fourteen years later</figcaption></figure>
<p>Notice it&#39;s not even a polite 404. <code>distilleryimage1.instagram.com</code> doesn&#39;t resolve at all — the photo is gone along with the entire piece of infrastructure that used to serve it. <a href="https://www.sean.cz/posts/2012/messengers-of-spring/">The post itself is still alive</a>, imported into this engine, minus the one photo nobody can download anymore.</p>
<p>Here&#39;s the uncomfortable part: the post was mine. The photo was mine. The URL never was.</p>
<h2 id="what-the-importer-does-about-it">What the importer does about it</h2>
<p>./blog.sh ships importers for twenty-two sources, and they all follow one stubborn rule: <b>media comes home</b>. Every image, video and audio file gets downloaded next to its post, into <code>media.nosync/&lt;year&gt;/&lt;slug&gt;/</code>, and the post references the local copy from then on. No hotlinks, no third-party CDN, no &quot;this content is no longer available&quot;.</p>
<p>A few details I&#39;m fond of:</p>
<ul><li>Downloads are measured on arrival. The build refuses to render an image without known dimensions — so the importer simply never writes one.</li><li>Failed downloads get retried. And when a file genuinely can&#39;t be fetched anymore, because the source deleted it years ago, you lose that one image — not the post.</li><li>The origin becomes a tag. Import an old Tumblr blog and every post lands tagged <code>tumblr</code>. Your old blog turns into a browsable archive instead of a zip file in a drawer.</li></ul>
<h2 id="the-numbers">The numbers</h2>
<p>From a real migration of a real fourteen-year archive:</p>
<div class="table-wrap"><table><thead><tr><th>Source</th><th style="text-align:right">Items in the archive</th><th>Notes</th></tr></thead><tbody><tr><td>Mastodon</td><td style="text-align:right">6,591</td><td>2,984 replies and 1,059 boosts skipped on purpose</td></tr><tr><td>Pixelfed</td><td style="text-align:right">333</td><td>two accounts, 571 media attachments</td></tr><tr><td>Tumblr</td><td style="text-align:right">1,099</td><td>four separate blogs, importing 1,247 media files that came home with them</td></tr><tr><td>Twitter</td><td style="text-align:right">5,388</td><td>2008–2022, with 1,134 replies and 417 retweets in the pile — and 610 media files</td></tr></tbody></table></div>
<p>Here&#39;s a handful from that archive — photos that spent years on one platform or another, and now live as files next to this post:</p>
<div class="photo-grid"><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/02.jpg" width="640" height="640" alt="The Astronomical Clock in Prague at night" loading="lazy" decoding="async"><figcaption>The Astronomical Clock — Hipstamatic, 2019</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/03.jpg" width="640" height="640" alt="Prague Castle photographed through a tower filter" loading="lazy" decoding="async"><figcaption>Prague Castle — Hipstamatic, 2019</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/04.jpg" width="640" height="640" alt="The Emauzy monastery towers" loading="lazy" decoding="async"><figcaption>Emauzy — Hipstamatic, 2019</figcaption></figure><figure><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/05.jpg" width="1280" height="1280" alt="A metal sculpture lit at night" loading="lazy" decoding="async"><figcaption>Spholio, a sculpture at night — 2022</figcaption></figure><figure class="span-2"><img src="https://blogsh.app/posts/2026/fourteen-years-every-image-came-home/06.jpg" width="1280" height="1707" alt="A photo taken on a Palm phone" loading="lazy" decoding="async"><figcaption>Shot on a Palm phone — 2019, because the archive keeps the weird experiments too</figcaption></figure></div>
<h2 id="the-point">The point</h2>
<p>None of this is clever engineering. Downloading a file and putting it in a folder is about as advanced as computing gets. The clever part was done by every platform that convinced us it wasn&#39;t necessary.</p>
<p>An archive is only yours if the files are yours. Everything else is a lease — and fourteen years is long enough to watch a few landlords disappear.</p>]]></description>
  <category>migration</category><category>philosophy</category>
</item>
<item>
  <title>Why I built this (38 seconds)</title>
  <link>https://blogsh.app/posts/2026/why-i-built-this/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/why-i-built-this/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:31 +0000</pubDate>
  <description><![CDATA[<figure><audio controls preload="metadata" src="https://blogsh.app/posts/2026/why-i-built-this/01.m4a"></audio><figcaption>Why I built this — 38 seconds, one take, recorded on a phone</figcaption></figure>
<p>Thirty-eight seconds, recorded on a phone, no edit. The written version is below if you&#39;d rather read.</p>
<p>There are hundreds of static site generators. I wrote another one anyway, and the reasons fit in less than a minute.</p>
<p>My posts lived on big platforms for fourteen years. Some of those platforms changed the rules. Some just disappeared. I spend my whole day in a terminal, so I wanted to write my blog there too. And a small personal blog does not need a database — files are enough.</p>
<p>So: posts are files, the site is a build, and comments live on the Fediverse. That&#39;s the whole idea.</p>]]></description>
  <category>philosophy</category>
</item>
<item>
  <title>No gems (one asterisk)</title>
  <link>https://blogsh.app/posts/2026/no-gems-one-asterisk/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/no-gems-one-asterisk/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:25 +0000</pubDate>
  <description><![CDATA[<p>./blog.sh needs no gems. Here&#39;s the one asterisk on that sentence, because a claim without its exception isn&#39;t worth much.</p>
<p>The claim first: zero gems, zero npm packages, no Bundler, no lockfile, no <code>node_modules</code> folder quietly gaining weight in the dark. Ruby 2.7 or newer and bash. Clone it and it runs.</p>
<h2 id="the-asterisk-up-front">The asterisk, up front</h2>
<p>Two optional sidebar widgets — Pixelfed and generic RSS — parse XML with <code>rexml</code>. That&#39;s a <i>default gem</i>: it ships bundled with every normal Ruby installation, so for most people the claim holds as written. But some Linux distributions split Ruby into packages and put the default gems in a separate one.</p>
<p>So: if you enable those two widgets on a distro-packaged Ruby and the build complains about <code>rexml</code>, you&#39;re the asterisk. Two ways out — install your distro&#39;s <code>ruby-rexml</code>-ish package, or <code>gem install rexml</code>. Either way it&#39;s one package, once, and only if you use those widgets at all.</p>
<p>That&#39;s the entire dependency story. I&#39;d rather write it out than round it down to zero and wait for someone&#39;s build to disagree.</p>
<h2 id="what-a-real-zero-buys-you">What a real zero buys you</h2>
<p>No network dependencies at build time means the entire site builds offline. On a plane, on a train, on a server that firewalled itself into a corner — <code>ruby build/build_blog.rb</code> neither knows nor cares.</p>
<p>And the output is as boring as the input. Measured on this site, today, fourteen posts:</p>
<div class="table-wrap"><table><thead><tr><th>Measured on this site</th><th style="text-align:right">Value</th></tr></thead><tbody><tr><td>Full build, cold</td><td style="text-align:right">1.9 s</td></tr><tr><td>Full build, warm (only changed files written)</td><td style="text-align:right">0.9 s</td></tr><tr><td>Homepage HTML</td><td style="text-align:right">54 kB</td></tr><tr><td>All CSS</td><td style="text-align:right">24 kB</td></tr><tr><td>All JavaScript</td><td style="text-align:right">23 kB — 555 lines across 8 files</td></tr><tr><td>Requests to third-party domains</td><td style="text-align:right">1*</td></tr></tbody></table></div>
<p>That JavaScript is the complete inventory: theme toggle, lightbox, client-side search, the comments loader, sidebar widgets, mobile nav, scroll-to-top, and a shared utility file. No framework is hiding under any of them.</p>
<p>And yes, a second asterisk snuck into the table, same policy as the first: the one third-party request is my own self-hosted Umami analytics, on another domain of mine, added through the config. The engine itself ships exactly zero — no fonts CDN, no tracker, no embed phoning home. Turn the analytics line off in <code>site.yml</code> and the number is a structural 0.</p>
<h2 id="why-bother">Why bother</h2>
<p>Because every dependency is a small standing appointment with the future. Some Tuesday it needs updating, audits, replacing, or it deprecates the one function you used. A stdlib-only tool skips those appointments — the price is writing a bit more code yourself, and for a blog engine that price turned out to be surprisingly low.</p>
<p>A zero with its exception written down is more useful than a zero without one. If you trust the asterisk, you can trust the rest of the table.</p>]]></description>
  <category>philosophy</category><category>build</category>
</item>
<item>
  <title>Why not X, and why Threads waits</title>
  <link>https://blogsh.app/posts/2026/why-not-x/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/why-not-x/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:21 +0000</pubDate>
  <description><![CDATA[<blockquote>Post read: $0.005 per resource. Post creation: $0.015 per request — $0.200 when it carries a URL. Free public read access: none.<cite>— X API pay-per-use pricing, docs.x.com, checked July 2026</cite></blockquote>
<p>Two networks are in. Two are not — and here&#39;s the whole reasoning, because &quot;we decided against it&quot; is not a reason.</p>
<p>./blog.sh announces every published post on Mastodon or Bluesky, and the replies to that announcement become the post&#39;s comments. For that to work, the engine needs two things from a network: a way to post, and a way for a <i>browser</i> to read a public thread. Cheap, ideally free, forever — because a personal blog is a decades-long project with a hobby budget.</p>
<h2 id="x-no">X: no</h2>
<p>Look at that price list again. Announcing a post with a link costs real money. Loading the reply thread — which happens every time anyone scrolls to the comments — costs real money, per read, forever. On a personal blog, that&#39;s a subscription to your own comment section, billed by your readers&#39; curiosity.</p>
<p>There&#39;s no engineering answer to a pricing decision. So: no.</p>
<h2 id="threads-not-yet">Threads: not yet</h2>
<p>Threads is genuinely feasible, and the design is sitting in a drawer. But look at what &quot;feasible&quot; means here:</p>
<blockquote>Publishing and reading replies to your own posts works — server-side only, through a registered Meta developer app, with OAuth and 60-day tokens.<cite>— the Threads API docs, summarized</cite></blockquote>
<p>In practice that&#39;s a cron job refreshing tokens so they never silently expire, and comments cached into JSON with roughly a half-hour delay — instead of the live thread Mastodon and Bluesky hand any browser for free. It&#39;s all buildable. It&#39;s just a lot of standing machinery for a network nobody has asked me about yet. The day someone does, the drawer opens.</p>
<h2 id="scraping-threads-no-and-firmly">Scraping Threads: no, and firmly</h2>
<p>The tempting shortcut — skip the API, scrape the web app — fails three ways at once. The app&#39;s internals change constantly and no community project maintains a stable interface to them. Meta blocks datacenter IPs and forbids automated collection in its terms, so shipping a scraper in a community engine means handing every user a feature that breaks without warning and can put their account at risk. And the paid scraping services fail the same test with extra steps: per-request pricing, your data flowing through a third party, and a dependency on someone else&#39;s ongoing legal cat-and-mouse.</p>
<h2 id="the-scoreboard">The scoreboard</h2>
<p>Mastodon and Bluesky are in because they pass the boring test: free to post, free for a browser to read a public thread, no tokens with expiry dates in the critical path. Any network that starts passing it is welcome. Any network that stops — well, now you know the exit criteria too.</p>]]></description>
  <category>philosophy</category><category>comments</category>
</item>
<item>
  <title>What ./blog.sh doesn&#39;t do</title>
  <link>https://blogsh.app/posts/2026/what-blog-sh-doesnt-do/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/what-blog-sh-doesnt-do/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:19 +0000</pubDate>
  <description><![CDATA[<p>A feature list tells you what a tool wants to be. This is the other list.</p>
<h2 id="features-that-aren-t-there-and-aren-t-coming">Features that aren&#39;t there — and aren&#39;t coming</h2>
<ul><li class="task-item"><input type="checkbox" disabled> Multiple authors — the engine is built around one person&#39;s workflow, and every multi-author feature taxes the single author with roles, permissions and attribution UI</li><li class="task-item"><input type="checkbox" disabled> Plugins — a plugin API is a promise to keep internals stable forever; a tool this small should be forked, not extended</li><li class="task-item"><input type="checkbox" disabled> A WYSIWYG editor — your <code>$EDITOR</code> took you years to configure; I&#39;m not going to compete with it in a textarea</li><li class="task-item"><input type="checkbox" disabled> Its own comment system — storage, moderation, spam, GDPR, and a database, all to rebuild what the Fediverse already does in public</li><li class="task-item"><input type="checkbox" disabled> Themes as downloadable packages — seven colour keys and your own banner get you further than a theme marketplace, at zero marketplace</li></ul>
<p>Unchecked checkboxes, as rendered by the engine&#39;s own task lists. They&#39;ll stay that way.</p>
<h2 id="what-the-parser-refuses">What the parser refuses</h2>
<p>The Markdown dialect turns things down too — each considered, each rejected, reasons attached:</p>
<ul><li>Underscore italics (<code>_like this_</code>)<ul><li>underscores live inside ordinary text: <code>file_names</code>, <code>snake_case</code></li><li>the cost of supporting them lands on everyone who never uses them, as surprise italics mid-identifier</li></ul></li><li>Code blocks indented with spaces<ul><li>collides head-on with nested-list indentation</li><li>the backtick fence says what it means</li></ul></li><li>Headings underlined with <code>===</code><ul><li>a line of dashes already means a horizontal rule and the frontmatter delimiter</li><li>one more meaning for the same characters is one too many</li></ul></li><li>Nested quotes (<code>&gt;&gt;</code>), reference links (<code>[text][id]</code>), footnotes<ul><li>each adds parser states whose main output, in practice, is surprising rendering of innocent text</li></ul></li></ul>
<p>Notice the pattern: it&#39;s the same reason every time. <b>The cost of a feature lands on the people who don&#39;t use it</b> — as complexity, as surprise, as one more way for plain text to mean something you didn&#39;t intend. A feature has to clear that bar, not just be useful to someone, somewhere, occasionally.</p>
<h2 id="the-upside-of-no">The upside of no</h2>
<p>Every &quot;no&quot; above is load-bearing. No plugins is why a fresh clone runs. No WYSIWYG is why the whole thing works over SSH. No own comments is why there&#39;s no database, and no database is why backup is <code>tar</code> and migration is a <code>for</code> loop.</p>
<p>This list isn&#39;t an apology. It&#39;s most of the design.</p>]]></description>
  <category>philosophy</category>
</item>
<item>
  <title>Built for one blog on purpose</title>
  <link>https://blogsh.app/posts/2026/built-for-one-blog/</link>
  <guid isPermaLink="true">https://blogsh.app/posts/2026/built-for-one-blog/</guid>
  <pubDate>Fri, 31 Jul 2026 22:17:11 +0000</pubDate>
  <description><![CDATA[<p>This is not a general-purpose engine, and pretending otherwise would waste your afternoon.</p>
<p>./blog.sh was built around exactly one deployment: one author, one archive, a terminal, and comments on the Fediverse. That&#39;s usually the part a project hides on the &quot;about&quot; page. I&#39;d rather lead with it, because it&#39;s also the explanation for everything opinionated about this tool. A general-purpose generator solves the general case and then makes <i>you</i> configure your way back to your specific one. This engine started at the specific case and never left.</p>
<p>So here&#39;s the honest table:</p>
<div class="table-wrap"><table><thead><tr><th></th><th>./blog.sh</th><th>Hugo</th><th>Jekyll</th><th>Ghost</th><th>WordPress</th></tr></thead><tbody><tr><td>Database</td><td>none</td><td>none</td><td>none</td><td>required</td><td>required</td></tr><tr><td>Dependencies</td><td>Ruby stdlib*</td><td>one binary</td><td>gem ecosystem</td><td>Node + MySQL</td><td>PHP + MySQL</td></tr><tr><td>Comments</td><td>Fediverse, built in</td><td>bring your own</td><td>bring your own</td><td>native + members</td><td>native + plugins</td></tr><tr><td>Deploy</td><td>6 backends, guarded</td><td>bring your own</td><td>bring your own</td><td>it&#39;s a server</td><td>it&#39;s a server</td></tr><tr><td>Multiple authors</td><td>no</td><td>yes</td><td>yes</td><td>yes</td><td>yes</td></tr><tr><td>Plugins / ecosystem</td><td>no, on purpose</td><td>huge</td><td>huge</td><td>good</td><td>unbeatable</td></tr><tr><td>Community size</td><td>population: 1</td><td>enormous</td><td>large</td><td>large</td><td>a third of the web</td></tr><tr><td>10,000-post build</td><td>slower</td><td>seconds, wins easily</td><td>slow</td><td>n/a</td><td>n/a</td></tr><tr><td>Newsletter</td><td>no</td><td>no</td><td>no</td><td>yes, native</td><td>via plugins</td></tr></tbody></table></div>
<p><i>(the asterisk is documented — one optional widget needs </i><i><code>rexml</code></i><i> on some distros)</i></p>
<p>Read the losing rows first; that&#39;s what they&#39;re there for. Hugo on ten thousand posts is dramatically faster, and if raw build speed on a huge archive is your constraint, take Hugo and my blessing. Ghost has a real newsletter business built in. WordPress has an ecosystem nobody catches up to, including a plugin for whatever you just thought of. A comparison table where the home team wins every row isn&#39;t a comparison — it&#39;s an ad with borders.</p>
<p>What the table can&#39;t show is the shape of the wins. &quot;No database&quot; isn&#39;t one row — it&#39;s why backup is <code>tar</code>, why hosting is any static server, why nothing needs a security patch on a Tuesday. &quot;Comments built in&quot; means the announcement toot, the reply thread and the unpublish cleanup are one feature, not three services on three bills.</p>
<p><b>Who this is for:</b> one writer with a terminal, an archive they want to own outright, and an account on the Fediverse or Bluesky.</p>
<p><b>Who this isn&#39;t for:</b> teams, clients, editorial workflows, anyone who needs a web admin, anyone whose readers must comment without leaving the page.</p>
<p>Built for exactly one blog — which is why it&#39;s this opinionated, and why it fits the blogs shaped like it unusually well.</p>]]></description>
  <category>philosophy</category>
</item>

  </channel>
</rss>
