Guide

Internationalisation (i18n)

i18n is opt-in. Existing sites, including sites using the legacy compact languages option, keep their current behaviour until i18n.enabled is set.

Configuration

 1languages:
 2  - code: pl
 3    locale: pl-PL
 4    name: Polski
 5    timezone: Europe/Warsaw
 6  - code: en
 7    locale: en-GB
 8    name: English
 9    timezone: Europe/London
10default_language: pl
11
12i18n:
13  enabled: true
14  prefix_default_language: false
15  translations_dir: i18n
16  dictionary_fallback: true
17  missing_translation: warn # error, warn, fallback, empty
18  invalid_language: fail    # fail, warn
19  duplicate_translation: fail # fail, warn
20  fallback_languages:
21    pl: [en]
22    en: []

The compact form remains valid:

1languages: [pl, en]
2default_language: pl
3language_timezones:
4  pl: Europe/Warsaw

With prefix_default_language: false, Polish /o-nas/ and English /en/about/ are generated. Setting it to true produces /pl/o-nas/ and /en/about/.

Content translations

Connect translated files with a stable key; translated slugs may differ:

1title: About
2slug: about
3lang: en
4translation_key: about
5status: publish

When lang is absent it resolves to the language its section assigns (language_sections, below) and then to default_language. If translation_key is absent, SSG derives it deterministically from the source filename. Duplicate (translation_key, lang) pairs, unknown languages, fallback cycles, invalid timezones and output collisions are validated before rendering.

Templates receive .Page.Lang, .Page.Locale, .Page.TranslationKey, .Page.Translations, .Site.Language, .Site.Languages, .Site.DefaultLanguage, .Site.LanguagePages and .Site.LanguagePosts.

{{range .Page.Translations}}
  <a href="{{.URL}}" hreflang="{{.Lang}}" {{if .IsCurrent}}aria-current="page">{{.Lang}}</a>

Root .Translations holds the same variants and marks the current one the same way, so either spelling builds a switcher. Before 1.8.62 only .Page.Translations set IsCurrent, and a switcher written against the root value highlighted nothing.

upper renders a language code the way a switcher usually shows it, without a branch per language — {{upper .Lang}} gives PL. CSS text-transform changes only the glyphs, not the accessible name or an attribute.

Helpers: hasTranslation, translationURL, languageURL, localizeDate and t. Existing helpers such as formatDatePL remain available.

A language for a whole section

1language_sections:
2  de: de
3  fr/blog: fr
4  home: en

Migrated sites are where this matters. A bilingual WordPress site keeps its languages in /de/ and /fr/ and says so nowhere a page carries: the language was a plugin's property of the section, not a field on the post. The export produces a few hundred documents with no lang at all, and writing one into every file is undone by the next export — a migration is run again whenever the source changes.

Keys are content directories relative to the source, longest prefix wins, and home names the site root — the same convention output_encoding_sections and schema_defaults use. A page's own lang: still wins; a section that claims no page changes nothing; and a section naming a language languages: does not declare warns once for the section rather than once per file.

The language is assigned before translation grouping, prefixing and hreflang, so everything downstream sees it. A page carrying an explicit link: keeps that URL untouched, so link: /de/impressum/ does not gain a second /de/.

Translation dictionaries

Place pl.yaml, en.yaml or JSON equivalents in translations_dir:

1navigation:
2  home: Strona główna
3post:
4  reading_time: " minut"

Use {{t "navigation.home"}} or {{t "post.reading_time" (dict "count" .ReadingTime)}}. Values are returned as ordinary strings and remain subject to html/template escaping. Only named placeholders are substituted; catalog values are never executed as templates.

The generated 404

ssg writes a 404.html when the site does not provide one, because a static host with no 404 falls back to index.html and answers 200 — so every dead URL becomes, to a crawler, a live page duplicating the home page. After a migration, dead URLs are what old links produce.

That page reads three keys, and translating it is the only way to change its language:

1not_found:
2  title: "404 — nie znaleziono strony"
3  body: "Ta strona nie istnieje w serwisie ."
4  home: "Przejdź na stronę główną"

All three or none. A page with two translated lines, one English one and lang="pl" is worse than a wholly English page: the mislabelled part is exactly the part a screen reader gets wrong, and nobody proof-reads a 404. With any key missing, the English copy is used whole and the page is labelled en.

lang follows the copy that was actually used, which is why the attribute alone was never the fix — it describes the language of the content, and declaring pl over English text makes a screen reader switch voice for words that did not change.

The page speaks the default language. One root 404.html answers every unmatched path, whatever language the visitor was reading, so it uses the language the site declares first. Before 1.8.62 it used whichever language the build rendered last, which on an en/pl/ro site was Romanian.

A theme owns this entirely if it wants to: a page slugged 404 renders to /404.html, and ssg then leaves it alone. That page's canonical, og:url and hreflang name /404.html too, so strict link checking passes on it. not_found_off suppresses the page.

Generated output

Pages, aliases, home pages, pagination, JSON records, Atom feeds and search indexes follow the configured prefix rule. Sitemap entries contain XHTML language alternates and x-default — for pages and posts from their translation group, for the front pages from the page that resolves at each one (or, without one, from the front page every language gets), and for the post listings (/blog/, /pl/blog/) from each other. Opt-in SEO output includes Open Graph locale metadata and JSON-LD inLanguage.

Each language gets its own Atom feed (/feed.xml, /pl/feed.xml), and the autodiscovery <link> injected into a page names the feed of that page's language, so subscribing from a Polish page subscribes to the Polish posts.

rewrite_md_links is language-aware: a link like installation.md resolves to the active language's translation of that document. An explicit language-suffixed link (installation.en.md) keeps the author's choice. When the active-language translation does not exist, the content_fallback chain (fallback_languages → default language) applies only when i18n.content_fallback: true; otherwise the link is left untouched and a warning names the link and language (once per pair).

Deferred features

Planned follow-ups, not yet implemented: language-scoped taxonomy pages (category/tag/author/series listings are still cross-language), a language selector and t labels inside the built-in themes (the output <html lang> is already corrected at render time), localized month names in localizeDate, and plural rules.

Migration

Add the i18n section only when ready. Start by assigning lang and a shared translation_key to variants, then add dictionaries and a language selector. Run a clean build after enabling i18n so obsolete unprefixed artifacts do not remain in the output directory.