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 (.pot template, .po catalogs, compiled .mo).
  • The framework’s LocaleManager initialises Flask-Babel and the feature loader registers every feature’s translations/ directory automatically.
  • The active locale is chosen per-request from the session (set by a language switcher), the Accept-Language header, 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.

  1. Config stays in sync. BABEL_TRANSLATION_DIRECTORIES is a ;-joined string of every registered directory.
  2. The live list is updated. Appending to app.extensions["babel"].translation_directories reaches 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 .mo files at runtime.
  • Feature translations/ directories are registered automatically during feature loading; never register them by hand.
  • The /lang/<code> switcher and its UI live in splent_feature_theme; install that feature to give users a language picker.

Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es