Juicer
English

Internationalization (i18n)

Publish a site in more than one language — language-prefixed URLs, per-language navigation, translated UI strings, and automatic hreflang.

A juicer site can ship the same content in several languages. Each language gets its own content tree, its own URL space, and its own navigation; pages that exist in more than one language cross-link automatically, and the UI chrome (button labels, “Previous”/”Next”, search placeholder, …) is translated through dictionaries. Single-language sites pay nothing for any of this — none of it activates until you declare a languages list.

This page documents every moving part. The juicer.build docs you’re reading are themselves bilingual (English + French), so it’s all dogfooded.

Turning it on

Declare the languages and the default in site.toml:

languages             = ["en", "fr"]
defaultLanguage       = "en"
defaultLanguageInRoot = true
  • languages — the list of language codes you publish. An empty or absent list means a single-language site (no behavior change at all).
  • defaultLanguage — the fallback language. A missing translation (of a page or of a UI string) falls back to this language. Defaults to the first entry of languages; on a site with no languages it defaults to "en".
  • defaultLanguageInRoot — when true, the default language is served at the site root with no /<code>/ prefix, and only the other languages get a prefix. When false (the default), every language is prefixed, including the default.

Content layout

Once languages is set, content lives one directory deeper — under content/<lang>/:

content/
├── en/
│   ├── _index.md
│   └── getting-started/
│       ├── _index.md
│       └── installation.md
└── fr/
    ├── _index.md
    └── getting-started/
        ├── _index.md
        └── installation.md

A page’s language is the first path segment under content/. Two pages that share the same path after the language segment — en/getting-started/installation.md and fr/getting-started/installation.md — are translations of each other. You don’t have to translate everything: a page that exists in only one language simply has no translations, and nothing links to a missing one.

URLs

defaultLanguageInRoot decides the shape of the URL space. With the config above (en default, in root):

PageURL
content/en/getting-started/installation.md/getting-started/installation/
content/fr/getting-started/installation.md/fr/getting-started/installation/

The default language keeps the clean, prefix-free URLs — important when you add a second language to an existing site and don’t want to break inbound links. Set defaultLanguageInRoot = false to prefix every language symmetrically (/en/… and /fr/…).

Tip

Building language-prefixed links by hand is error-prone. Use the relLangURL / absLangURL helpers — they apply the right prefix for you.

Per-page language data

Every page exposes its language and its translations on the rendering context:

  • .page.lang — the page’s language code ("" on a single-language site).
  • .page.translations — the list of the other-language versions of this page. Each entry carries lang, title, and url. Empty when the page has no translation.
  • .page.languagesevery configured language (in declared order), each with lang, a url to switch to, and a current flag. The url is the page’s own URL for the current language, its translation where one exists, and that language’s home otherwise — so the entry is never missing, even on an untranslated page. Empty for single-language sites.

.page.languages is what a full language switcher iterates — it always offers every language, the way Starlight or Docusaurus do:

{{ if .page.languages }}
<details aria-label="{{ i18n .page.lang 'aria_language' }}">
  <summary>{{ i18n .page.lang 'langname' }}</summary>
  {{ for l <- .page.languages }}
  <a href="{{ l.url }}" hreflang="{{ l.lang }}"{{ if l.current }} aria-current="true"{{ end }}>
    {{ i18n l.lang 'langname' }}
  </a>
  {{ end }}
</details>
{{ end }}

The juicerdocs theme ships this as a dropdown in its topbar. (Use .page.translations instead when you want a switcher that lists only the translations that exist — handy for an inline two-language toggle.)

UI strings: the i18n helper

Chrome text — button labels, pager arrows, the search placeholder — is looked up by key instead of hard-coded, so it can be translated:

{{ i18n .page.lang 'nav_prev' }}

i18n takes a language code and a key. Lookup falls back in this order:

  1. the requested language’s dictionary,
  2. the defaultLanguage‘s dictionary,
  3. the literal key (so a missing string is visible, not blank).

Because the language is the first argument, {{ i18n t.lang 'langname' }} looks up a key in another language’s table — which is how the switcher above shows each language’s own name.

Dictionaries

Strings come from i18n/<lang>.toml files — flat key = "value" tables:

# i18n/fr.toml# i18n/fr.toml
nav_prev = "Précédent"
nav_next = "Suivant"

Dictionaries are collected from two places and merged:

  • the active theme(s)<theme>/i18n/<lang>.toml
  • your site<src>/i18n/<lang>.toml

with the same precedence as every other themed resource: your site wins over a theme, and an earlier theme in the lookup chain wins over a later (inherited) one. The merge is per-language and per-key, so a theme can ship a complete English chrome dictionary while your site adds only the handful of keys it overrides — or the extra languages it publishes.

Note

This is why a theme like juicerdocs renders English chrome with zero configuration: it ships its own i18n/en.toml. To translate the chrome, drop an i18n/fr.toml into your site (or override individual keys); you never have to fork the theme.

Language-aware navigation

The sidebar walks .site.root.subsections. On a multilingual site, .site.root is scoped to the current page’s language — it resolves to that language’s <lang>/_index.md. So a French page gets the French section tree, an English page the English one, with no per-theme work. A section that hasn’t been translated yet simply doesn’t appear in that language’s navigation.

.page.prev / .page.next — the sequential pager — are scoped the same way: navigation walks the current language’s reading order only, so the last English page never points “next” into French.

Language-aware URLs

relLangURL and absLangURL are the language-aware companions of relURL / absURL. They take the language as the first argument and prefix the path with that language’s URL segment:

<a href="{{ relLangURL .page.lang '/getting-started/' }}"></a>

On an English page (default, in root) this yields /getting-started/; on a French page, /fr/getting-started/. Use these for fixed links in shared layouts — like a hero call-to-action — so they stay within the reader’s language. (.page.url and .page.translations[].url are already language-correct; you only need relLangURL for paths you write by hand.)

Sitemap & hreflang

When a site is multilingual, sitemap.xml automatically gains <xhtml:link rel="alternate" hreflang="…"> entries linking each page to its translations, so search engines serve the right language. Single-language sites get a plain sitemap with no hreflang noise. No configuration required either way.

What stays zero-config

Warning

Everything on this page is dormant until languages is set. A site with no languages list builds byte-for-byte as it did before i18n existed: .page.lang is "", .page.translations is empty, the sitemap has no hreflang, content stays at content/ (not content/<lang>/), and the i18n helper still resolves theme dictionaries through the default-language fallback so themed chrome renders in English.

Search

Esc
to navigate to open Esc to close