Internationalization (i18n)
SPLENT ships native internationalization on top of Flask-Babel. Each feature carries its own .po translation catalogs, and the framework auto-registers them at startup so strings resolve at runtime without any product-level wiring.
Table of contents
Overview
i18n in SPLENT is per-feature and convention-driven.
- Each feature keeps its own
translations/directory (.pottemplate,.pocatalogs, compiled.mo). - The framework’s
LocaleManagerinitialises Flask-Babel and the feature loader registers every feature’stranslations/directory automatically. - The active locale is chosen per-request from the session (set by a language switcher), the
Accept-Languageheader, or the product default. - Products only opt in by declaring
BABEL_DEFAULT_LOCALE/BABEL_SUPPORTED_LOCALES. No other configuration is required.
This keeps features self-contained. A feature like splent_feature_events can ship Spanish translations, and the product just enables the locale.
Product configuration
Translation is a product-level decision. A product declares which locales it supports and which one is the fallback, in its config.py (or via the matching .env keys).
class Config:
BABEL_DEFAULT_LOCALE = "en"
BABEL_SUPPORTED_LOCALES = ["en", "es"]
| Setting | Default | Description |
|---|---|---|
BABEL_DEFAULT_LOCALE |
"en" |
Fallback locale when neither the session nor the Accept-Language header resolves a supported locale. |
BABEL_SUPPORTED_LOCALES |
["en"] |
Locales the product accepts. Used for Accept-Language negotiation and to validate the language switcher. |
Both keys have defaults set by LocaleManager, so an English-only product needs no configuration at all.
app.config.setdefault("BABEL_DEFAULT_LOCALE", "en")
app.config.setdefault("BABEL_SUPPORTED_LOCALES", ["en"])
Marking strings for translation
In Jinja2 templates
Wrap user-facing text in _(). Use named placeholders (%(name)s) so translators can reorder them. Here is an example from splent_feature_events (templates/events/list.html).
{% block hero %}
{{ render_block('hero', eyebrow=_('Programme'), title=_('Events'),
subtitle=_('Talks, workshops and competitions.')) }}
{% endblock %}
The same _() works for inline fallbacks.
{% else %}
<p class="card__text">{{ _('No events yet.') }}</p>
{% endfor %}
In Python code
Import gettext from flask_babel and alias it to _.
from flask_babel import gettext as _
# Simple string
flash(_("Invalid credentials"), "danger")
# With interpolation — named params, so translators can reorder
flash(_("Email %(email)s is already in use", email=email), "danger")
The language switcher
A user-facing switcher is provided by splent_feature_theme. It exposes a /lang/<code> route that writes the chosen locale into the Flask session, where LocaleManager.get_locale() reads it back on the next request.
Here is routes.py.
from flask import current_app, redirect, request, session
from splent_io.splent_feature_theme import theme_bp
@theme_bp.route("/lang/<code>", methods=["GET"])
def set_language(code):
"""Language switcher: store the chosen locale in the session (read back by
the framework's LocaleManager) and return to the previous page."""
supported = current_app.config.get("BABEL_SUPPORTED_LOCALES", ["en"])
if code in supported:
session["locale"] = code
return redirect(request.referrer or "/")
The route only accepts codes that are in BABEL_SUPPORTED_LOCALES, then redirects back to the referring page so the switch is seamless.
Switcher UI
The public shell (public_base.html) renders the switcher from site.locales (the supported list) and highlights site.locale (the active one). It only appears when more than one locale is available.
{% if site.locales and site.locales | length > 1 %}
<span class="lang-switch">
{% for lc in site.locales %}<a class="lang-switch__opt {{ 'is-active' if lc == site.locale else '' }}" href="{{ url_for('theme.set_language', code=lc) }}">{{ lc | upper }}</a>{% endfor %}
</span>
{% endif %}
Each option links to theme.set_language with its locale code; the active locale gets the is-active class.
Locale selection
For every request, LocaleManager.get_locale() resolves the active locale in priority order.
| Priority | Source | How it is set |
|---|---|---|
| 1 | session["locale"] |
Written by the /lang/<code> switcher. |
| 2 | Accept-Language header |
Sent by the browser, best-matched against BABEL_SUPPORTED_LOCALES. |
| 3 | BABEL_DEFAULT_LOCALE |
Product fallback (via Flask-Babel when no match is found). |
This is the selector registered with Flask-Babel.
def get_locale():
"""Select the best locale for the current request."""
# 1. Explicit session override (set by a language switcher)
locale = session.get("locale")
if locale:
return locale
# 2. Accept-Language header negotiation
from flask import current_app
supported = current_app.config.get("BABEL_SUPPORTED_LOCALES", ["en"])
return request.accept_languages.best_match(supported)
It is wired into Babel at init time.
_babel = Babel(app, locale_selector=get_locale)
Per-feature translation directories
Each feature stores its catalogs inside its source package.
splent_feature_events/
└── src/splent_io/splent_feature_events/
├── routes.py
├── templates/
└── translations/
├── messages.pot # Extracted template (source of truth)
└── es/
└── LC_MESSAGES/
├── messages.po # Human-edited Spanish translations
└── messages.mo # Compiled binary (loaded at runtime)
messages.pot. Extraction template; all translatable strings found in the feature’s Python and Jinja2 files.messages.po. One per locale; created from the.pot, then edited by a translator.messages.mo. Compiled binary that Flask-Babel reads at runtime.
These directories are registered automatically; see below.
How feature translations are registered
Features load after Flask-Babel is initialised. As each feature is loaded, the framework calls LocaleManager.register_translation_dir(app, translations_dir) for its translations/ directory.
Flask-Babel reads BABEL_TRANSLATION_DIRECTORIES once, in init_app, and caches the resolved list on its BabelConfiguration (app.extensions["babel"].translation_directories); it does not re-read the config afterwards. Because features register their catalogs after init, register_translation_dir keeps the config in sync and appends to the already-computed live list so the new directory takes effect.
@staticmethod
def register_translation_dir(app, translations_dir: str) -> None:
"""Register a feature's translations/ directory with Babel.
Called by the FeatureIntegrator after loading each feature.
"""
if not os.path.isdir(translations_dir):
return
dirs = app.extensions.get("splent_translation_dirs", [])
if translations_dir not in dirs:
dirs.append(translations_dir)
# flask-babel computes its translation directories ONCE in init_app
# (BabelConfiguration.translation_directories) and never re-reads the
# config. Features load AFTER Babel is initialised, so we (1) keep the
# config in sync and (2) mutate the live BabelConfiguration list
# (app.extensions["babel"]) so the new directory actually takes effect.
app.config["BABEL_TRANSLATION_DIRECTORIES"] = ";".join(dirs)
babel_cfg = app.extensions.get("babel")
live = getattr(babel_cfg, "translation_directories", None)
if isinstance(live, list) and translations_dir not in live:
live.append(translations_dir)
logger.debug("Registered translations: %s", translations_dir)
Two parts make this work.
- Config stays in sync.
BABEL_TRANSLATION_DIRECTORIESis a;-joined string of every registered directory. - The live list is updated. Appending to
app.extensions["babel"].translation_directoriesreaches the list Flask-Babel consults at lookup time, so directories registered after init are honoured.
A product that enables es and installs features shipping es catalogs gets working Spanish strings with no extra wiring.
CLI catalog workflow
The full catalog lifecycle is driven by the feature:translate command. Run each step against the feature you are translating (using events as the example).
1. Mark strings in code
Wrap text in _() in templates and Python (see Marking strings for translation).
2. Extract translatable strings
splent feature:translate events --extract
Scans the feature’s Python and Jinja2 files and writes translations/messages.pot. (A babel.cfg is auto-created on first extract if it does not exist.)
3. Initialise a locale
splent feature:translate events --init es
Creates translations/es/LC_MESSAGES/messages.po from the .pot template. If the locale already exists, it merges/updates instead.
4. Edit the .po
Open translations/es/LC_MESSAGES/messages.po and fill in the msgstr fields.
msgid "Events"
msgstr "Eventos"
msgid "Talks, workshops and competitions."
msgstr "Charlas, talleres y competiciones."
msgid "No events yet."
msgstr "Aún no hay eventos."
5. Compile
splent feature:translate events --compile
Generates the messages.mo binaries from every .po. These are what Flask-Babel loads at runtime.
6. Enable the locale in the product
Add the locale to the product’s config.
BABEL_SUPPORTED_LOCALES = ["en", "es"]
Restart the product. The theme switcher now offers ES, selecting it stores session["locale"] = "es", and _()-wrapped strings render from each feature’s compiled catalog.
Notes
- Translations resolve per feature. If two features define the same
msgid, each resolves against its own catalog directory. - Compile (
--compile) before deployment, since Flask-Babel only reads.mofiles at runtime. - Feature
translations/directories are registered automatically during feature loading; never register them by hand. - The
/lang/<code>switcher and its UI live insplent_feature_theme; install that feature to give users a language picker.