# 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

```yaml
languages:
  - code: pl
    locale: pl-PL
    name: Polski
    timezone: Europe/Warsaw
  - code: en
    locale: en-GB
    name: English
    timezone: Europe/London
default_language: pl

i18n:
  enabled: true
  prefix_default_language: false
  translations_dir: i18n
  dictionary_fallback: true
  missing_translation: warn # error, warn, fallback, empty
  invalid_language: fail    # fail, warn
  duplicate_translation: fail # fail, warn
  fallback_languages:
    pl: [en]
    en: []
```

The compact form remains valid:

```yaml
languages: [pl, en]
default_language: pl
language_timezones:
  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:

```yaml
title: About
slug: about
lang: en
translation_key: about
status: 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`.

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

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

```yaml
language_sections:
  de: de
  fr/blog: fr
  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`:

```yaml
navigation:
  home: Strona główna
post:
  reading_time: "{{count}} 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:

```yaml
not_found:
  title: "404 — nie znaleziono strony"
  body: "Ta strona nie istnieje w serwisie {{site}}."
  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.

## Internal Markdown links

`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.
