Publishing in more than one language

Localization — part 6 of 6

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]
# config/site.sk.yml -- what "this site in Slovak" means
ui_language: cs
language_name: "Slovenčina"

The Slovak branch is then built and is Slovak in every way a reader or a crawler can see -- /sk/ addresses, <html lang="sk">, its own hreflang -- while the engine's own furniture (Read more, the dates, the search box) is Czech. Borrowing a near language beats publishing an English-speaking Slovak site, and unlike the silent inheritance it is a decision somebody made.

language_name is the one word a borrowed interface cannot lend: what the language is called in itself, which is what the language switcher says of the language a click leads to. The engine has it for every language it speaks; for one it does not, the switch would say only the code -- "SK" beside "English" and "Deutsch" -- and ./blog.sh check says so while the line is missing. (A language the engine does speak may be named here too; the site's word wins.)

The other two ways out are to write locales/sk.yml -- the engine takes translations, see the top of this page -- or to stop publishing the language.

What the site looks like

The language in site.lang keeps the site root and every other one gets a root of its own:

/                 the site's own language
/de/              every listing, tag, series, feed and post that has German words
/assets/          one copy, shared
/write/           one writing app, shared
/robots.txt       one copy -- a crawler reads the origin's root and nothing else
/sitemap.xml      one copy, naming every language and their alternates
/stats.json …     the sidebar's data, refreshed by cron at the root

The root is not /cs/ on a Czech site, and that is deliberate: every link anyone has ever made to the site goes on working the day a second language is added.

What a language says about itself

Everything that is true of ONE language lives in a file of its own, config/site.<lang>.yml, beside the config:

config/site.yml        the site, and the one line that names its languages
config/site.de.yml     what "this site in German" means
config/site.sk.yml     ...and in Slovak

Start one from config/site.lang.yml.example: copy it to config/site.de.yml (or whichever language), and it says the rest in its comments. Like site.yml, these files are the site's own, not the engine's, and git ignores them.

Two kinds of thing go in one. What the language DOES -- fallback, ui_language and language_name, the first two below and the third above -- and what the site SAYS about itself in that language: the same keys config/site.yml has for its words, only in other words. The build of that language lays them over site.yml; whatever the file leaves out stands as site.yml has it.

# config/site.cs.yml -- blogsh.app in Czech
site:
  title: "./blog.sh"
  description: "Minimalistický blogovací engine, který ovládáš z terminálu"
banner:
  claim: "jen ./blog.sh"
about:
  heading: "O projektu"
  html: "Blog.sh je …"
footer:
  note_heading: "Licence"
  copyright: "Texty a obrázky © 2026 Daniel Šnor."
nav:
  - { label: "Vše", url: "/" }
  - { label: "Začni tady", url: "/posts/2026/blog-sh/" }
  - { label: "Proč", tag: "philosophy" }

Some headings need no translating at all. "About" over the about card, "Links" and "Find me on" over the footer columns, and each widget's heading ("Recent toots", "Recent commits", ...) are the engine's own words when site.yml does not write them -- and then every language says them in its own words, from the engine's locale files. Write one in site.yml only to say something else, and translate that one here; write it as "" for no heading at all. The wizards leave the engine's words out of site.yml for the same reason.

The keys it takes, and nothing else: site.title, site.short_name, site.description; banner.claim, banner.alt; about.heading, about.html; footer.links_heading, footer.links, footer.note_heading, footer.note_html, footer.copyright, footer.social_heading; nav; heading under a widget; tags; and series. Everything else in site.yml -- the picture in the banner, the author, the address, the colours, the widgets' accounts -- is a fact about the site, the same in every language, and a translation of it would be a second site.

A tag is an ID with a word per language. A post's tags are metadata, written once in the site's own language like its date, and a tag has one page in every language: /cs/tag/philosophy/ is the Czech page of the tag philosophy. What a Czech reader sees for it -- on the pill, over its page, in its feed, in the tag index and in what the search box finds -- is the word tags: gives it:

# config/site.cs.yml
tags:
  philosophy: "filozofie"
  authoring: "psaní"

Keyed by the tag as the posts write it, and matched the way the engine matches tags, so Philosophy: names the same one. A tag with no word here shows as written. A word for a tag no published post carries is shown nowhere; check says so, and the build goes on -- tags come and go with the posts, and the build runs after every save.

A series is the same: one ID, a name per language. A post says which series it belongs to once, in the site's own language, and the series has one address in every language -- /cs/series/the-new-site/ is the Czech page of that series. The name a Czech reader sees over it, in the sentence that places each part and in the page's title is the one series: gives:

# config/site.cs.yml
series:
  "The new site": "Nový web"

Keyed by the name as the posts write it, or by the series' slug -- both fold to the same thing. A series with no name here shows as written, and a name for a series nobody writes is shown nowhere.

A list with places in it names the same places. The menu and the footer links are written out in full, the way site.yml has them, but every item goes where the same item goes in site.yml, in the same order; only the label changes. An item left without a label of its own -- or a footer link without a title -- is shown under the word the site's own list has for it, and check says which one. A menu that drifts apart between languages is a second site, and a reader who switches language would lose the item they were reaching for. The address is still the one the site's own language writes -- /posts/2026/blog-sh/ above -- and the Czech build takes it to the Czech copy of that post, as it does for the site's own menu. A footer link goes the same way.

The rules, which the build and check both enforce: the site's own language has no file (site.yml is it); a file for a language site.locales does not name is refused rather than ignored; so is a key the engine does not read, a translation of something site.yml does not have (a heading for a widget the site has not set up, a menu on a site whose menu is the engine's, an about text or a footer note site.yml does not write -- it would be a text only this language shows), and a menu or footer list that goes to other places than site.yml's. The exceptions are the texts that stand in for something when site.yml leaves them out: the engine's own headings (about.heading, footer.links_heading, footer.social_heading) and banner.claim, which is the description until it is written -- every language shows one of those anyway. A tag given an empty word is refused too; leave it out to show it as written. A language file that is not valid YAML stops the build of every language, and check and doctor both name it.

The writing app is not translated this way: it is one copy for the site, and it speaks the site's own language, because that is the language its author writes in.

What a post carries

Metadata once, words per language. A post with no translations key is a post in the site's own language -- which is every post in every archive today, so nothing has to be migrated:

{
  "slug": "muj-post",
  "title": "Český titulek",
  "date": "2026-09-20T10:00:00+02:00",
  "tags": ["ruby"],
  "content": [ ... ],
  "translations": {
    "de": { "title": "Deutscher Titel", "content": [ ... ] }
  }
}

translations never holds the site's own language: the post is its own text there. An entry filed under that code is not read, and check names it -- which is also what changing site.lang over an archive of translations leaves behind: site.lang says what language the posts are written in.

A language counts as written when it has a BODY. A title with nothing under it is a translation somebody started: it stays in the archive and check --languages shows it as started, but the language is not offered for that post and no page is built there -- a headline in one language over the text of another is the page nobody wants. Words with no title of their own are fine: that is an untitled post, named from its own first sentence, in its own language.

A translation may carry title, content and excerpt, and nothing else. The date, the tags, the series, the pin and the state belong to the post and hold in every language at once -- so a post cannot be published in one language and a draft in another.

Write one with ./blog.sh translate <slug> --lang de (see Writing); the editor shows the title and the body, because that is all there is to it.

A link in a translation leads where it is written. The lines of the original the editor shows carry the original's links, and a link to another post of the site copied from there -- /posts/2026/muj-post/ -- takes the German reader to the Czech page, though that post may have a German one. Nothing rewrites it and check has nothing to say, since the address is a sound one: write the address of the page in the translation's own language, /de/posts/2026/mein-post/. (An item of the menu is different -- it follows the piece, not the spelling.)

What happens to a post nobody has translated

It is shown, not hidden. It stays in the other language's listing, and the link on it leads to the address the post actually has -- so the same words never stand at two addresses, and nothing needs a canonical.

The switcher is a chip in the banner's corner, beside the light/dark button: which language you read the site in is a choice about this visit, like which mode you read it in, and it belongs where that choice already lives rather than in the menu, which it breaks in two. It shows language CODES (CS/DE/EN) with the language a click leads to named in the title, the way the button beside it shows ☀︎/☾︎ and says what it does in a title. A click anywhere on the chip moves to the next language and wraps around at the end -- one target, like the button, rather than a row of small ones to aim at.

The switcher shows every language the site publishes, and a click leads to the next one the page is IN: a language with nothing of the current post in it is passed over (its code stays on the chip, marked is-elsewhere), so the texts a post does have reach each other however many languages stand between them. Only when no other language has the post does the click lead on, to the next language's front page rather than to a page that is not there. What the hreflang alternates name is narrower and on purpose: only addresses that exist. A site that promises a crawler an address it never wrote sends readers from a search result to its own 404.

Building and deploying

./blog.sh rebuild produces every language the site publishes, in one go and under one lock, into a single tree -- so the deploy that follows sees one site and nothing has to be merged afterwards. Each language's build sweeps only its own root, which is how two of them can share a tree without carrying each other away.

Adding a language grows the build by a whole tree of its own, and taking one away shrinks it by as much -- which is exactly what the deploy's guards exist to stop. They know the difference: the deploy says which languages changed, leaves those trees out of its comparison and keeps holding the rest of the site to the usual limits. Nothing needs --force. And the files of a language the site stops publishing are deleted from the target by the next deploy, without --prune -- a language taken out of site.locales is taken off the web too.

Its config/site.<lang>.yml has to leave with it: the build stops on a file for a language the site does not publish, and names it (so does ./blog.sh doctor). Move the file out of config/ rather than deleting it -- the translated title, menu and footer in it are exactly what you will want back if the language returns.

The address in each language

A translated post is served under an address of its own: /de/posts/2026/mehrsprachiger-blog/, not the Czech slug with a German prefix. An address is read by people, and half of it in a language they do not read is the half that says what the piece is about.

It is made from the translated title the first time the translation is written, and then left alone -- a post's own slug works that way for the same reason: an address that follows a title somebody corrected is an address that breaks every link to it. To choose one, put it in the header the translate editor opens:

---
title: Mehrsprachiger Blog
slug: mehrsprachiger-blog
---

Two posts cannot share an address in one language: the command says whose it already is and leaves you in the header to pick another.

🪤 The post's own slug is not this. It names the post's file and its media directory -- identity, which does not change with the language somebody reads in. Changing a translated address changes where that language serves the post and nothing else; no redirect is left behind, so it is worth choosing before anybody links to it.

When a language has nothing for a post

It is shown anyway, in the nearest language that does have it, and the link on it goes to that copy. Which language is nearest is yours to say:

# config/site.yml
site:
  lang: en
  locales: [en, cs, de]
# config/site.de.yml -- what German does when it has nothing
fallback: [cs]

A German page with no German words then carries the Czech ones and links to the Czech page, rather than the English ones the site would otherwise fall back to. Say nothing and the site's own language stands in, which is what every site does today.

The site's own language has no file of its own -- config/site.yml is its file -- and that is also why it cannot be given a chain: it is where every chain ends. Written INTO a chain it ends the chain there too: fallback: [cs, de] on a site whose own language is Czech is "Czech", and German after it is never reached. A chain runs only through languages the site publishes; one that names another, or the language itself, or a language after the site's own, has an entry that stands in for nothing, and ./blog.sh check names it.

The words and the address come from the same answer, always: Czech words under a link to the English page would be worse than either on its own. And the chain never builds a page -- a language has a page only for a post that has words in it, so the same text never stands at two addresses.

Seeing what is written and what is not

./blog.sh check --languages

A row per post, a column per language: a tick where it is written, a half circle for a title with no text yet, a dot where that language has nothing. Only on request, because on an archive of thousands it is a document rather than a summary -- the ordinary check is read for what is wrong, and a post that exists in one language is not wrong.

One state in it is a fault and is reported by an ordinary check: two posts asking for one address in one language. translate refuses to write that, so it arrives by hand or from an import -- and the build of that language stops on it, so ./blog.sh rebuild deploys nothing until one of the two is given another address.

Publishing a post that is not in every language

publish and schedule refuse it, name the languages it has no words in and offer --allow-partial in the same sentence. A site that publishes in several languages usually means to publish in all of them at once, and the exceptions are worth saying out loud rather than discovering later.

A site where it is the other way round -- most posts stay in one language, and a translation is the exception -- says so once, in config/site.yml:

publishing:
  allow_partial: true

That is --allow-partial for every post. publish and schedule no longer refuse, the wizard no longer asks, and a post sent from a phone with publish: yes is published instead of being kept as a draft. The post goes out in the languages it has; the others show it the way they show any post that was never translated (see fallback: above). Only true says it -- left out or false, nothing changes, and ./blog.sh doctor names a value that is neither. A program reads the setting as languages.allow_partial in props <slug> --json.

The cron that publishes the queue never asks: a post got there through schedule, where somebody already answered this. That holds for a post scheduled while the site had its languages. One that was already in the queue when a language was added was never asked about it -- it goes out on its day in the languages it has, and rescheduling it does not ask either. check --languages shows which languages every post has, the queued ones among them; schedule <slug> --cancel and scheduling it again is how the question is put to it.

Pages

A page (About, Contact) is translated exactly as a post is, and is the thing a reader of the other language reaches for first. Its address has no year in it, so the translated one is a root of that language: /de/ueber-mich/. That is where the engine writes its own names too, so a translated page cannot be addressed assets or write any more than an untranslated one can -- the refusal says so and leaves you in the header to pick another.

The menu

The menu is built once per language, and each kind of entry answers differently:

the entryin /de/
the default menu (All, the types)the German listings
tag:the tag's German listing
url: naming a post or a pagethat piece's German address, which is a different slug -- or the one copy of it, if it has no German words
url: naming a listing the engine builds (/search/, /archive/), the front page / or the feed /rss.xmlits German copy
anything else -- an address off the site, a file you put there yourselfexactly as written

The one to watch is the fourth row: url: /o-mne/ is not turned into /de/o-mne/, because the German page is at /de/ueber-mich/. The item follows the piece rather than the spelling.

That is where each item GOES. What it SAYS is translated in the language's own file, item by item, to the same places -- see What a language says about itself. Without one, a hand-written menu keeps site.yml's labels in every language; the engine's default menu speaks every language it knows.

A link post

The link a link post is about belongs to the post, not to a language: it is the same page whatever language you read the write-up in. So it is not in the translation editor, and every language's page carries it. Write the words under it and nothing else.

What is not there yet

  • Pictures in a translation. Media belong to the post and its languages share them; add them with edit.
  • A post's tags and series are written once. Both are translated by the language's file, not on the post -- see What a language says about itself; do not give a post a second tag in the other language, which makes two tag pages for one subject.

Submitting

A pull request with the one or two files is enough. Say whether you are a native speaker; a locale reviewed by one is marked as shipped in the README, others as community drafts. When the engine later grows new keys, they arrive in English via the fallback -- your locale keeps working and can catch up whenever.