i18n

Governance rules

This page covers the mechanics of translating interface strings — navigation labels, ARIA text, button copy, status messages. It does not cover writing bilingual content pages; that editorial rule lives in Bilingual pages.

Where strings live#

Every translatable UI string is a key in i18n/en.toml and i18n/fr.toml, one TOML table per key:

[skipToContent]
other = "Skip to content"

The French file carries the same key with the French value:

[skipToContent]
other = "Aller au contenu"

There is no fallback language. A key defined in one file and not the other renders as an empty string on whichever site is missing it — see Missing keys fail silently below.

Key naming#

Keys are camelCase and describe the string’s role, not its location, matching the general naming rule in Naming conventions: mainNavigation, languageSwitcher, footerSiteInformation, dataTableScrollRegion, skipToContent. A component-specific label is prefixed with the component: codeBlockScrollRegion, not scrollRegion.

Reuse a key across templates when the string is genuinely the same UI concept. Do not reuse a key just because two strings happen to read the same today — a shared key means a future edit to one context silently edits the other.

Calling a key from a template#

<nav class="nav-strip" aria-label="{{ i18n "mainNavigation" }}">

The function reads the current page’s language automatically — the same call resolves to Main navigation on the English site and Navigation principale on the French one. Calls work identically inside attributes, visible text, and JSON blocks such as the app-config script tag that hands JavaScript its localized strings.

Plural forms#

A key that varies by count defines more than one table — one for the singular case, other for everything else — and the template passes the count as the second argument:

[readingTime]
one = "1 min read"
other = "%d min read"
{{ i18n "readingTime" $page.ReadingTime }}

Hugo picks the table by the target language’s CLDR plural rules, not just “is it 1” — French and English happen to agree on singular/plural at 1, but do not assume every language does.

Missing keys fail silently#

Hugo does not warn or error on an i18n call whose key is undefined in the current language. The function returns an empty string, and the template renders whatever surrounds it — an aria-label="", an empty visible label, a blank line in app-config. A full hugo --gc build stays green.

This has already shipped once: five templates were edited to call new i18n keys, the keys were added to a scratch note instead of i18n/en.toml, and four navigation landmarks went live with aria-label="" for a period before the gap was caught by grepping template usage against the TOML files, not by the build.

Before shipping a new or renamed key:

  • add it to both i18n/en.toml and i18n/fr.toml in the same change
  • grep the key name across layouts/ to confirm every call site has a matching definition
  • check the rendered HTML of an affected page, not just a clean build — a clean build proves the template compiled, not that the string resolved

Language alternates#

layouts/_default/baseof.html renders one <link rel="alternate" hreflang="…"> per entry in .Translations — the other language versions of the current page — plus one self-referencing alternate for the current page itself, which is the pattern search engines expect: every language version links to every version, including itself.

The visible language switcher is a separate component, covered in Language toggle: it resolves the current page’s translation per site through AllTranslations, and falls back to that language’s home page when no translation exists, so the control never points at a 404.

Where UI strings are wired today#

Most i18n calls sit in the templates that render them directly: layouts/partials/header.html (navigation, language switcher), layouts/partials/footer.html, layouts/partials/search.html and search-modal.html, layouts/partials/docs/breadcrumbs.html, and the render hooks for code blocks and callouts. A handful of keys are collected into the app-config JSON block in baseof.html so assets/js/site.js can read localized strings without a template round-trip — the search placeholder and error messages work this way.