Text

Verifying your locale

Key parity against English (empty arrays = complete):

ruby -ryaml -e '
def keys(h, p = ""); h.flat_map { |k, v| v.is_a?(Hash) ? keys(v, "#{p}#{k}.") : ["#{p}#{k}"] }; end
en = keys(YAML.load_file("locales/en.yml")); xx = keys(YAML.load_file("locales/de.yml"))
p missing: en - xx, extra: xx - en'

Then see it live: set site.lang: <lang> in config/site.yml, and

ruby build/build_blog.rb        # the site side
./blog.sh preview                # read a post page, /search/, /markdown/, /404.html
./blog.sh help                   # the CLI side
./blog.sh                        # the wizard menu
./blog.sh doctor                 # and check, stats, export -- the reporting sections
./import.sh --help

A placeholder typo shows up immediately as a literal %{...} in the output -- worth grepping the rendered public.nosync/ for %{ before submitting.

What doesn't localize (yet)

  • Right-to-left languages. The templates don't mirror the layout (dir="rtl" is not set anywhere), so an Arabic or Hebrew locale would render left-to-right. Honest status: not supported until someone does the layout work, which is CSS and templates, not YAML.
  • Slugs stay ASCII. Titles are transliterated (NFKD plus a table for ß, ł, œ and friends -- see lib/slug.rb); scripts that don't transliterate (CJK, Cyrillic, Arabic) produce an id-based slug instead. URLs work either way.

Publishing in more than one language

site.lang is the language the site is WRITTEN in; site.locales is the list of languages it is PUBLISHED in:

site:
  lang: cs
  locales: [cs, de]

Both locale files have to exist (locales/cs.yml, locales/de.yml) -- that is the first half of this page.

A language the engine has not been translated into

The engine ships en, cs and de. Naming any other language in site.locales stops the build and says so: without words of its own that branch would quietly inherit English, which is worse than being told.

There are three ways out, and the third one is why this section exists:

# config/site.yml -- the site, and one sentence about its languages
site:
  lang: cs
  locales: [cs, sk]
Read more

Content model

How the engine works inside -- one page, following a post's life from keystroke to visitor. The why behind these shapes lives in Decisions.

$EDITOR (markdown) → CLI parse → JSON blocks → build (ERB) → static HTML → deploy backend → host
                                      ↑                                        ↑
                            importers target the                      cron refreshes widget
                            same schema                               JSON + post stats

One post = one JSON file at content.nosync/posts/<year>/<slug>.json:

  • slug, title, date (ISO 8601), tags, state (published/draft), source (which platform it came from), optionally mastodon_url (its comment toot), draft_token and created_at (drafts), type (explicit content type override).
  • content is an array of typed blocks: text (with subtype heading1-6/quote), list, table, code, image, video, audio, file, link, chat, hr.
  • Inline formatting (bold/italic/strikethrough/code/link) is stored as codepoint offset ranges into plain text, not nested HTML -- the same NPF-style shape Tumblr's API uses, which is what the importers naturally produce.
  • Media lives next to its post in media.nosync/<year>/<slug>/, referenced by bare filename; nothing is ever hotlinked.
Read more

The markdown round-trip

Markdown exists only at the editing boundary; storage and build never see it:

Read more

Undoing an edit (lib/post_versions.rb)

Deleting a post was always reversible -- it goes to trash/ and restore brings it back -- but editing one was not, and editing is what happens every day: a paragraph dropped and saved, a round-trip through markdown that could not express something, a paste into the wrong place. So the post as it stood is copied aside immediately before it is overwritten, by whoever is doing the overwriting: PostWriter.write for a re-import, the edit and restore paths in scripts/manage_post.rb for everything a person does by hand.

The store is deliberately plain: content.nosync/versions/<year>/<slug>/, one whole post JSON per file, named for the second it was kept (20260813-174204-118.json) so that sorting the directory is sorting by time and no index has to be maintained. Ten per post; the eleventh drops the oldest, because the newest copy answers "what did this say before I broke it" in nine cases out of ten and an unbounded history of an archive this size is a second archive. PostVersions.move carries the directory into the trash with the post and back out on a restore, or a restored post would come back with amnesia and a deleted one would leave its history orphaned where nothing points at it.

Read more

Importing

An import splits along the line between what every platform needs and what only one does, so that adding a source means writing its mapping, not another copy of the plumbing every existing adapter shares:

Read more

Exporting (lib/exporter.rb)

The mirror of the importers, and a much smaller thing: one source (this archive), one destination format, no adapters. ./blog.sh export walks content.nosync and writes a Jekyll-shaped tree -- _posts/<date>-<slug>.md, _drafts/<slug>.md, pages at the root, media copied under assets/<year>/<slug>/. It only reads: nothing in the archive is touched, which is what lets it be the one command that still works on an installation somebody is leaving (hence its own entry point in scripts/export.rb, needing neither env.sh nor a config that parses).

Three details carry the design:

Read more

Checking the archive (lib/checker.rb)

./blog.sh check is doctor's counterpart and deliberately a separate command: doctor answers "is this installation sound", reads a handful of config values and takes a second, while this walks every post and every media file there is. Rolled into one command the fast half would stop being run, which is the half somebody runs before every deploy.

Read more

Counting (lib/stats.rb)

./blog.sh stats is the same shape as check and export: its own entry point, the archive on disk as its only input, no configuration it could die on. lib/stats.rb counts and returns a plain Hash -- it never prints, colours or translates -- and scripts/stats.rb renders that either as a screen for a person or, with --json, verbatim for whatever reads it next. That split is what keeps the two outputs from drifting: there is one set of numbers and two ways of showing it.

It reuses what already answers each question rather than re-deriving it: PostText.plain for words (the same text the search index is built from), ContentType.dominant for what a post is, FileSize.human for sizes, and the build's own 200-words-per-minute constant for reading time -- two places telling a reader how long something takes must not disagree.

On this day (lib/on_this_day.rb)

One module decides which posts from earlier years stand on a date and which of them the card shows; the command (scripts/on_this_day.rb), the build and the cron all call it, so what the author sees in the terminal is what a reader gets. It is data in, data out: entries (day, year, time, titled, word count, plus whatever the caller carries) and a date go in, windows of rows come out. It never reads the config and never decides a language.

The split between build and cron is the part worth knowing. What a post is called and where it lives depends on the language -- a translation's own title and address, a fallback's, the site's -- and only the build knows that. The day, on the other hand, changes at midnight, when only the cron runs. So every build run writes a catalogue for its language beside the build cache (.on-this-day.<lang>.json: every listed post with its selection facts, its title and its address in that language, and the tree it belongs to), plus today's file; the cron re-chooses from the catalogues and rewrites a file only when its content changed. A build with the card off removes its catalogue, and the run that owns the site root removes catalogues of languages the site stopped publishing -- otherwise the cron would keep writing a tree nobody builds.

Read more

Build pipeline (build/build_blog.rb)

The script is the pass itself; the domains it calls live beside it, one file each -- build/blocks.rb (a content block becomes HTML), build/output.rb (writing, pruning, and getting a file into public.nosync), build/feeds.rb (RSS, Atom, JSON Feed, sitemap), build/discovery.rb (the search index and what feeds it), build/cards.rb (link and social cards), build/languages.rb (which language a run renders, where its reader is sent, and the checks a language configuration has to pass), build/tags.rb (the tag pages and the index of them), build/archive.rb (the archive map and a page per year) and lib/series.rb (what a series is and what order its parts go in, which the CLI needs too). The phases in tags.rb and archive.rb are functions with their inputs spelled out -- the posts, the tag map -- called from the point in the pass where they always ran, so the pages are written in the order they always were.

⚠️ Moving a method out of build_blog.rb is not a free refactor: the templates in templates/ are rendered against the script's own binding, so every helper they call has to stay reachable from there. A helper that moves into a module keeps a one-line wrapper at the top level, or the build dies on the first page that uses it.

A single linear pass, no framework:

Read more

The terminal UI

lib/tui.rb is the whole UI layer: colors, single-keypress choices, arrow-key menus, the screens they live on and a spinner -- pure stdlib (io/console), plain VT100 sequences, no terminfo and no gems. Four deliberate constraints:

Read more

Deploy (scripts/deploy_web.rb + lib/deploy_backend/)

The deploy script owns the what; backends own the how:

Read more

The client side

Small single-purpose vanilla JS files, no framework, no third-party origins. util.js loads first because everything else stands on it: Blog.escapeHtml, which every piece of foreign data goes through before it reaches innerHTML, and Blog.formatDate, which takes its locale from window.BLOG_I18N so a date the browser writes matches the ones the build wrote. Then, in load order:

Read more

Security

The threat model is a static site with no server of its own: nothing here listens on a port, so what remains to get wrong is what the pages carry, what the working directory holds, and the one thing that arrives from outside -- a post sent over the SSH the machine already has.

Read more

Content

Why the engine is shaped the way it is -- the trade-offs behind what Architecture describes. Format: what was decided, why, and what it costs.

Typed JSON blocks, parsed once -- not markdown rendered at build time. Markdown is parsed exactly once, when the author saves; the build only ever assembles already-structured blocks, and every importer targets the same schema, so imported and hand-written posts are indistinguishable downstream. Cost: editing needs the inverse renderer (markdown_writer.rb), and content markdown can't express is guarded by a loss check instead of just surviving a round-trip.

Inline formatting as offsets into plain text, not nested HTML. The NPF-style shape Tumblr's API already uses -- imports keep their formatting without HTML parsing, stored content contains no markup to sanitize, and escaping stays entirely the renderer's job. Cost: overlapping-span rendering is genuinely fiddly (see apply_formatting and render_markdown_range).

Read more

Publishing and comments

Comments are replies to an announcement post -- on exactly one network. No comment database, no moderation queue, no GDPR surface, no spam plugin -- replies live where people already are (Mastodon or Bluesky, whose thread APIs a visitor's browser can read directly -- when the server allows it. GoToSocial does not: reading a thread there needs a token on every request, with nothing to configure, and a Mastodon in secure mode behaves the same. On such a server the live mode cannot work at all, and comments.approval: fav -- where a cron reads the thread WITH a token and only starred replies reach the page -- is the whole of what is available. doctor --online asks the question and says which one you have). The two networks are deliberately exclusive: configuring both would split every post's discussion into two half-threads, so the build refuses it. Cost: comments require a presence on the chosen network, and deleting the announcement deletes the discussion (which unpublish does deliberately, to never leave an announcement pointing at a dead URL).

Read more

Build and deploy

Static output, rebuilt incrementally -- only changed bytes are ever written or uploaded. emit compares before writing, the deploy manifest diffs by hash (with a size+mtime fast path), and RSS avoids embedding "now". A one-character edit deploys a handful of files, which also keeps cloud-synced and content-hashed targets calm.

Since 1.6 there is a layer above that: the build records what went into each page and does not render the ones whose inputs have not moved, because comparing bytes still costs rendering them first. The record is never an authority -- anything it cannot vouch for is rendered and compared as before -- and it is thrown away whole whenever a template, a locale, the configuration or the engine changes, since any of those can reach every page.

Read more

Dependencies and security

Ruby stdlib only -- no gems, no Bundler; external binaries are fine. git clone + system Ruby is the entire installation, and nothing can bit-rot in a dependency tree. Where stdlib can't reach (rsync, git, rclone, sftp, $EDITOR), the engine shells out to system binaries the user already understands. Cost: some things are hand-rolled that a gem would provide -- multipart uploads, JPEG/MP4 header parsing, YAML-adjacent frontmatter, and a static file server for ./blog.sh preview (lib/preview_server.rb, plain TCPServer) instead of the webrick-dependent ruby -run -e httpd one-liner.

Read more

Terminal UI

Interactive niceties, but the plain path stays authoritative. Arrow menus, single-key answers and colors appear only on a real TTY; everything the CLI does must still work identically when piped, and no escape code may ever reach a log. Cost: two code paths in the dialogs -- worth it, since cron and scripts drive the same commands humans do.

No curses, no dependencies. io/console plus VT100 sequences cover what this needs. Cost: no complex layouts -- deliberately not the goal.

Read more

Configuration

config/site.yml (identity, versioned) vs env.sh (secrets, mode 600, never in git). Split by sensitivity, not by topic: the site config is meant to sync across environments so local and production render identically; tokens are meant to exist only where they're used.

Config written by the engine is edited as text, never dumped from a parsed hash. Loading config/site.yml, changing a key and writing it back is one line of Ruby and would have destroyed the thing that makes the file usable: most of its lines are comments and commented-out blocks, only a fraction are keys. The rest is the documentation for every setting the engine has, plus the commented-out blocks you uncomment when you want a widget or a custom font -- and real sites hand-edit around it (sean.cz keeps an <img> inside about.html's folded scalar). So lib/config_writer.rb substitutes values line by line and leaves every other byte identical, and a config that doesn't exist yet is seeded from the example verbatim so every key it will ever set is already present to be substituted into. Cost: a text editor for a structured format, which is only safe because it is anchored (key name plus the exact indentation its parent implies, searched only inside that parent) and verified afterwards by reparsing the file and comparing the values back. Prose comments in the template parse as keys otherwise -- # Optional: posts per listing page would index as Optional:.

Read more

Before the tag

A release of blog.sh is a git tag and a CHANGELOG entry. There is no build to produce, no package to publish and no artefact to upload: an installation upgrades with git pull, so what a tag means is "this is a state of main somebody deliberately blessed".

That is exactly why the order below matters. Everything that can be checked has to be checked before the tag, because after it there is nothing to take back: a tag that has been pulled is a tag that lives on other people's machines, and recutting it makes two different trees answer to one name.

1. The suite is green, in full. Not the files you touched -- ./tests/run_all.sh, all of it, and read the verdict properly: the run prints one summary line per file, so the number of === tests/ lines and the number of passed: lines have to match. A file that ends without reporting its own verdict exits zero with failed assertions inside it, and the run counts it as passed.

2. The docs describe what ships. Every feature is documented outside the changelog -- that is the suite's own rule and it has a test. When a release adds a setting, install.md gains it; when it adds a command, operations.md does. The changelog is where a change is announced, not where it is explained.

Read more

After the tag

7. The installations get it. They pull; nothing is pushed to them.

8. The release notes go out the same day. Hand-written, in the form the earlier releases use -- an opening line that says what the release is about, bold headings, em dashes, the licence and links at the foot. They are not generated from the changelog: the changelog says what changed and the notes say why somebody should care, and a generator can only ever produce the first one wearing the second one's clothes.

9. The post announcing it is part of the release, not a follow-up. Same day, on the project's own blog, in the same voice as the rest of it.

What is deliberately not here

No LTS branch. One maintainer, one line of development, and every installation on main: a maintenance branch would be a second tree to keep honest, and the honest state of it would be "untested". If a release ever needs fixing without the release after it, that is the moment to make one -- not before.

No release-notes generator. See point 8.

No CI badge on the public repository. The suite is not public, so a badge would either be a link to something nobody can open or a claim about a run nobody can see. The suite runs on every push in the private repository that holds it, and the verdict that matters is the one in point 1: a full green run, read properly, on the machine cutting the tag.