Multi-Language Content

Accent CMS can serve the same content tree in multiple languages. Translations live alongside the original files using a language suffix in the filename, the default language keeps its existing plain URLs, and every page receives a list of its own translations so templates can render a language switcher.

Multi-language support is opt-in. A site that does not configure site.languages (or lists only one language) behaves exactly as a single-language site: no language detection, no URL prefixes, no translation grouping. You can add i18n to an existing site without changing any current URLs.

Enabling i18n

Multi-language mode activates only when site.languages lists two or more language codes. Add the supported languages to config.yaml and make sure the default (site.language) is one of them:

site:
  name: My Website
  language: en            # default language -- owns the plain URLs
  languages: [en, de, fr] # all supported languages, in display order

Rules:

  • site.language is the default language. Its pages are served at their normal URLs with no prefix.
  • site.languages must include site.language. Validation rejects a config where the default language is missing from the list.
  • The order of site.languages is the display order used by language switchers.
  • With fewer than two entries, i18n stays off and the site is single-language.

See the Configuration reference for the full list of site keys.

Authoring a translation

Language is determined by a filename suffix, not by a frontmatter field. This is the single source of truth and cannot drift out of sync. To translate a page, add a sibling file whose stem ends in .<lang>:

content/01.home/
  default.md       # English  (default language)  -> /home
  default.de.md    # German   translation         -> /de/home
  default.fr.md    # French   translation         -> /fr/home

The same convention applies to file-style pages:

content/docs/
  intro.md         # English  -> /docs/intro
  intro.de.md      # German   -> /de/docs/intro

How suffixes are interpreted:

  • A file with no recognized language suffix is treated as the default language.
  • A suffix is only recognized when the code appears in site.languages. A file named notes.uk.md on a site whose languages list does not contain uk is treated as an ordinary page with the stem notes.uk – never as a translation. This keeps dotted filenames safe and makes i18n purely additive.

Frontmatter: localize values, not keys

Frontmatter keys are a fixed schema, not content. Keep them in their canonical form – the lower-case English names Accent ships (title, date, author, location, …) – in every language, and translate only the values:

# 02.events/sommerfest/default.de.md  (German translation)
---
title: Sommerfest 2026          # value translated
date: 2026-08-15
location: Zürich                # key stays `location`; the value is German
author: Redaktion
---

A localized key is not recognized as the field it resembles. Write Ort: instead of location: and Accent does not map it back – it lands in the untyped custom map (page.custom.Ort), invisible to typed fields, feeds, any template that reads the canonical name, and structured data. The filename suffix already carries the language, so the keys never need to change.

Keys and rich results (structured data)

This matters most for structured data: JSON-LD type detection keys on canonical field names. A page with a date and a location is auto-detected as a schema.org Event; rename location to Ort and the page silently falls back to a plain article – and the venue would be empty even if the type were forced, because the Event builder also reads location by its canonical name. Keeping the English key with a translated value is what keeps the Event (and its Place) intact in every language.

When you want the page type driven by section rather than by fields – an events listing whose pages carry no location, or simply to be explicit – map URL prefixes to schema types in config.yaml. Non-default languages are served under their /{lang}/ prefix, so map each one:

structured_data:
  type_mappings:
    "/events/":    Event   # default-language events
    "/de/events/": Event   # the German translations live here

For a single page, the most explicit and fully language-agnostic option is the per-page override, which bypasses both the heuristic and the URL prefixes:

---
title: Sommerfest 2026
schema:
  type: Event
---

The URL model

The default language owns the bare URLs; every other language is served under a /{lang}/... prefix:

FileLanguageURL
01.home/default.mden (default)/home
01.home/default.de.mdde/de/home
01.home/default.fr.mdfr/fr/home

Because the default language keeps its existing paths, adding i18n to a live site does not break any current links or bookmarks.

Each language has its own navigation tree: a page only appears in the hierarchy (breadcrumbs, siblings, children, menus) for its own language. An untranslated page simply does not exist in another language’s tree, so menus never show links that fall back to the wrong language.

page.language vs page.translations

Two page-context fields drive multi-language templates:

  • page.language – the content language of the page being rendered (for example "de"). This is what belongs in <html lang>. It is set for every content page (on a single-language site it is simply site.language), but it can be empty on pages built outside the content pipeline – taxonomy index and custom error pages. For <html lang>, use the or-chain shown below so those pages still resolve to a language.

  • page.translations – the list of every available translation of the current page, including the page’s own language. Each entry has:

    FieldTypeDescription
    languagestringLanguage code, e.g. "de"
    urlstringURL of that translation (default language has no prefix; others are under /{lang}/...)
    activebooltrue for the language currently being displayed

On a single-language site page.translations is empty, so a switcher guarded by page.translations | length > 1 renders nothing.

The companion site-context fields are site.languages (all configured codes) and site.current_language (the active language for this request). See the Page Context reference for the full field tables.

Building a language switcher

The default theme ships a ready-made partial driven by page.translations. Include it anywhere in a template – typically in the header next to the version switcher:

{% include "partials/language-switcher.html.jinja" %}

The partial renders the active language as plain text and the others as links, and renders nothing unless the page has two or more translations. Its source is a complete, minimal example you can copy and restyle:

{% if page.translations | length > 1 %}
<ul class="language-switcher" aria-label="Language">
  {% for t in page.translations %}
  <li>
    {% if t.active %}
    <span class="active" lang="{{ t.language }}">{{ t.language | upper }}</span>
    {% else %}
    <a href="{{ t.url }}" lang="{{ t.language }}" hreflang="{{ t.language }}">{{ t.language | upper }}</a>
    {% endif %}
  </li>
  {% endfor %}
</ul>
{% endif %}

Set the root element’s lang and dir from the page so assistive technology and typography track the content language:

<html lang="{{ page.language or site.current_language or site.language }}" dir="{{ page.direction or 'ltr' }}">

The or chain keeps the right language on pages built outside the normal pipeline (taxonomy indexes, custom error pages) where page.language may be empty. See Page Locale and Smart Punctuation for how page.language, page.locale, and page.direction relate.

Per-language feeds

When i18n is active, each language gets its own RSS feed. The default language keeps the canonical /feed.xml; non-default languages are served under their prefix:

LanguageFeed URL
en (default)/feed.xml
de/de/feed.xml
fr/fr/feed.xml

Static builds

accent build mirrors the served URLs:

  • Default-language pages are written to their normal paths (/home -> output/home/index.html).
  • Non-default languages are written under a language directory (/de/home -> output/de/home/index.html).
  • The sitemap includes hreflang alternates linking each page to its translations, so search engines can offer the right language.

Serve and build produce identical output, so a switcher that works under accent serve works the same way in a static build.

Summary

  • i18n is opt-in: it activates only when site.languages has 2+ entries.
  • Translations are sibling files with a .<lang> suffix; unrecognized suffixes are left as ordinary filenames.
  • Frontmatter keys stay canonical (English) in every language; translate only the values. A renamed key is treated as an unknown custom field and silently drops out of typed fields, feeds, and structured data (e.g. JSON-LD Event).
  • The default language keeps its plain URLs; other languages are served under /{lang}/....
  • page.language is the content language; page.translations lists every translation for building a switcher.
  • The partials/language-switcher.html.jinja partial is a working reference.
  • Per-language feeds and hreflang sitemap alternates are generated automatically.