Internationalization and localization
This guide explains how one Heine site can render configured locale trees. Start with the tutorial for a first multilingual site, and use the reference for the complete configuration and template contract.
Terms
Internationalization (i18n) is Heine's locale-aware site model: configured locales, source trees, page identity, routes, links, assets, data, and output checks. Localization (l10n) is the locale-specific result: messages, plural forms, formatted values, and authored page content.
Heine renders a finite configured set of locales at build time. URLs select a concrete locale; it does not negotiate a language at request time.
Locale configuration and routes
heine.toml declares a default locale and the finite set of supported
BCP-47 locales. A source directory is required for every configured locale.
[i18n]
default_locale = "en"
[i18n.locales.en]
direction = "ltr"
[i18n.locales.de]
direction = "ltr"
[i18n.locales.pl]
direction = "ltr"
The default locale is unprefixed. Every other locale has its locale code as a URL prefix.
content/en/about.page -> /about.html
content/de/ueber-uns.page -> /de/ueber-uns.html
Rendered routes continue to derive from checked source-relative paths. There is no i18n-specific route override.
Text direction
Each locale table requires direction = "ltr" or direction = "rtl". The
choice is explicit because a BCP-47 locale alone does not reliably establish a
writing direction: a language can be written in more than one script. Heine
exposes the configured identity as locale.code and locale.direction:
<html lang="{{ locale.code }}" dir="{{ locale.direction }}">
An RTL locale can therefore render alongside LTR locales in one site without
changing routes, content lookup, assets, translated-page links, or Fluent
message lookup. Heine sets no CSS direction rules and does not rewrite authored
content. Themes and sites should use logical CSS properties such as
margin-inline, padding-inline, border-inline-start, and text-align: end
for direction-neutral layout. Their font stack must cover every script they
publish.
For an embedded fragment whose direction differs from its page, mark the fragment in authored HTML or Markdown:
<blockquote lang="ar" dir="rtl">
<p>…</p>
</blockquote>
Use a nested dir only for a genuine directional boundary. Source-code blocks
normally remain LTR regardless of their containing locale.
Source trees
The source tree gives each role a distinct location.
content/
├── assets/ # shared copied assets; default-locale variants
│ └── images/logo.svg
├── en/ # default-locale pages, content, and local assets
│ └── index.page
└── de/ # German pages, content, and local assets
├── start.page
└── assets/
└── images/logo.svg # German overlay of the shared logo
data/
├── site.toml # shared structured data
├── en/
│ └── navigation.toml
└── de/
└── navigation.toml
locales/
├── en/
│ └── site.ftl
└── de/
└── site.ftl
content/assets/ is copied to the shared asset URL space. It is also the
default-locale form of an overridable shared asset. A non-default locale may
place the same logical asset below content/<locale>/assets/; it is selected
only while rendering that locale and receives that locale's URL prefix. This
is a narrow, deterministic overlay: Heine checks the current non-default
locale and then the shared asset. It never searches other locales.
The default locale does not overlay content/assets/: its desired variant is
the shared file itself. Defining the same asset below
content/<default-locale>/assets/ is a build error, rather than an implicit
precedence rule. Locale-specific assets without a shared counterpart belong
below their locale's source tree. Article assets may remain beside their
locale-specific article content.
Shared data remains below data/. Locale-specific data belongs below
data/<locale>/; it has no implicit fallback. Templates receive these as
separate data and locale_data values. They are
not merged:
# data/site.toml
name = "Example site"
# data/de/navigation.toml
label = "Blog"<p>{{ data.site.name }}</p>
{% set blog = page(id='blog/index') %}
<a href="{{ blog.url }}">{{ locale_data.navigation.label }}</a>
The second value exists only while rendering German pages. A missing
locale_data.navigation value is a template error; it does not read
data.navigation instead.
Page counterparts
Heine never infers that matching filenames or paths are translations. A page
may instead declare a translationid:
translationid = "welcome-post"
The same ID on pages from different locales creates a translation group. A group is intentionally partial: it may have English and German pages without Polish. A page belongs to at most one group, and a group may contain no more than one page from each locale. Duplicate pages in one locale are a build error that identifies both declarations.
The render-facing page view exposes only known counterparts through
page.translations. Its keys are locale codes and its values contain a
counterpart id plus output-relative path; use root with path, or the
checked page() helper, to create a link. A language switcher therefore
offers only existing pages; Heine never guesses a URL, redirects to an
unrelated language, or requires matching source-tree structures.
For example, this shared-header switcher renders links only for the current
page's known counterparts. The language-<locale> message IDs are a site
convention for localized labels; Heine does not prescribe label text.
{% if page %}
{% if page.translations %}
<nav aria-label="{{ t(id="language") }}">
{% for locale, translation in page.translations %}
{% set target = page(locale=locale, id=translation.id) %}
{% set label = "language-" ~ locale %}
<a href="{{ target.url }}" hreflang="{{ locale }}">{{ t(id=label) }}</a>
{% endfor %}
</nav>
{% endif %}
{% endif %}
page(id="...") addresses an exact page ID in the locale currently being
rendered. Its optional locale argument addresses an exact page ID in that
named locale; it never resolves a translation group implicitly. Markdown has
the equivalent checked page-link syntax: page:<id> addresses the current
locale, and page:<locale>:<id> addresses an explicit locale. Both support
query strings and fragments. It applies to Markdown
links, including reference-style links, but not to image sources. Template and
Markdown references share one page-resolution path and report a missing locale
or page at the authored call site.
Messages and formatting
Fluent resources under locales/<locale>/ provide reusable localized text.
The t() template function uses the locale currently being rendered and
returns escaped text.
# locales/en/site.ftl
welcome = Welcome, { $name }.
post-count = { $count ->
[one] One post
*[other] { $count } posts
}{{ t(id="welcome", name=data.site.name) }}
{{ t(id="post-count", count=posts.count) }}
Fluent messages may use named values, terms, attributes, message references,
and select expressions. Select expressions provide correct language-specific
plural categories and can also express gender or other controlled choices.
The initial value contract accepts strings, numbers, and booleans/select
values. Dates use the separate format_date() template function so the stable
UTC RFC 3339 timestamp can remain suitable for HTML datetime attributes. It
accepts the rendered page.published value and formats its calendar date for the current locale
with short, medium, or long styles. It formats calendar dates, not times
or time zones. The template boundary does not expose arbitrary objects, arrays,
raw HTML, Fluent internals, or filesystem access.
Keep the machine-readable value in datetime and format only the visible
text:
<time datetime="{{ page.published }}">
{{ format_date(value=page.published, style="long") }}
</time>
Long-form prose belongs in locale-specific content files, rather than being
assembled from message fragments. A template may safely place t() output
inside its HTML structure; messages themselves are not trusted HTML.
Message lookup first uses the current locale, then the configured default locale, but only when the requested message or attribute is absent. A malformed current-locale message or invalid formatting argument is not hidden by a fallback value. A message absent after fallback is a build error with the message call site and attempted locale chain. Invalid Fluent resources, duplicate IDs, invalid arguments, and invalid locale configuration are build errors. Heine does not use warnings for translation-resolution failures.
Render boundary and checks
Templates receive a small render-facing model, including locale, shared
data, locale_data, page, page.translations, t(), asset(), and
page(). They do not receive raw resource bundles, source paths, mutable site
state, or arbitrary access to every locale's content.
Heine owns locale resolution, fallback, translation-group checks, route and output collision detection, and all writes. Every generated page and asset still claims its output through the output domain before rendering.