188 lines
6.1 KiB
Markdown
188 lines
6.1 KiB
Markdown
# Translations
|
||
|
||
French is the primary language of the site. English remains available
|
||
through the switcher in the sidebar (and on the login page, for visitors
|
||
who have not signed in yet).
|
||
|
||
---
|
||
|
||
## 1. How it works
|
||
|
||
Source strings stay **in English** and act as gettext message ids. The
|
||
French wording lives in a catalogue.
|
||
|
||
```
|
||
app/i18n.py locale selection
|
||
app/translations/fr/LC_MESSAGES/messages.po French catalogue (edited)
|
||
app/translations/fr/LC_MESSAGES/messages.mo compiled (read at runtime)
|
||
app/translations/en/LC_MESSAGES/messages.po English, msgstr == msgid
|
||
babel.cfg extraction rules
|
||
```
|
||
|
||
This keeps the codebase in one language — the same one as its comments and
|
||
docstrings — while what a member sees defaults to French.
|
||
|
||
**A string with no translation falls back to English**, not to a raw
|
||
identifier. That is why this migration can proceed template by template
|
||
without ever leaving the site half broken: an untranslated page is a page
|
||
in English, not a page full of `nav.dashboard.label`.
|
||
|
||
### Which language a visitor gets
|
||
|
||
1. An explicit choice made through the switcher, kept in the session.
|
||
2. Failing that, the browser's `Accept-Language`, restricted to `fr` and `en`.
|
||
3. Failing that, French.
|
||
|
||
An explicit choice always wins, including over an English browser.
|
||
|
||
---
|
||
|
||
## 2. Marking a string for translation
|
||
|
||
### In a template
|
||
|
||
```jinja
|
||
<span>{{ _('Dashboard') }}</span>
|
||
<p>{{ _('The page you are looking for does not exist.') }}</p>
|
||
```
|
||
|
||
With a value inside:
|
||
|
||
```jinja
|
||
{{ _('Welcome back, %(username)s!', username=user.username) }}
|
||
```
|
||
|
||
For a longer block:
|
||
|
||
```jinja
|
||
{% trans %}This tryout has ended and can no longer be modified.{% endtrans %}
|
||
```
|
||
|
||
### In Python
|
||
|
||
```python
|
||
from flask_babel import gettext as _
|
||
|
||
flash(_('This account has been deactivated.'), 'danger')
|
||
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
|
||
```
|
||
|
||
### Two things that do not work
|
||
|
||
**Never build a sentence by concatenation.** Word order differs between
|
||
languages, and the translator sees fragments with no context.
|
||
|
||
```python
|
||
flash(_('Player ') + name + _(' has been removed.')) # no
|
||
flash(_('%(name)s has been removed.', name=name)) # yes
|
||
```
|
||
|
||
**Never translate at import time.** A module-level `_()` runs before any
|
||
request exists, so it resolves once, in whatever locale happened to be
|
||
active — usually the default. Use `lazy_gettext` when the string has to sit
|
||
in a constant or a class attribute:
|
||
|
||
```python
|
||
from flask_babel import lazy_gettext as _l
|
||
|
||
ROLE_LABELS = {'coach': _l('Coach'), 'player': _l('Player')}
|
||
```
|
||
|
||
Extraction picks up `_l` because `babel.cfg` is invoked with `-k _l`.
|
||
|
||
---
|
||
|
||
## 3. Updating the catalogues
|
||
|
||
After marking new strings:
|
||
|
||
```bash
|
||
# 1. Re-extract every marked string
|
||
pybabel extract -F babel.cfg -k _l -o messages.pot --project=team-tryouts .
|
||
|
||
# 2. Merge into the existing catalogues, keeping current translations
|
||
pybabel update -i messages.pot -d app/translations
|
||
|
||
# 3. Fill in the new French entries
|
||
# edit app/translations/fr/LC_MESSAGES/messages.po
|
||
|
||
# 4. Compile
|
||
pybabel compile -d app/translations
|
||
```
|
||
|
||
`messages.pot` is regenerable and not tracked. The `.po` and `.mo` files
|
||
**are** tracked: deployment is a plain file mirror with no build step, so
|
||
an uncompiled catalogue would mean an English-only site in production.
|
||
|
||
### Entries needing attention
|
||
|
||
`pybabel update` marks changed strings as `#, fuzzy`. A fuzzy entry is
|
||
**ignored at runtime** — the string falls back to English. Review the
|
||
guessed translation, then remove the `#, fuzzy` line.
|
||
|
||
### Adding a language
|
||
|
||
```bash
|
||
pybabel init -i messages.pot -d app/translations -l es
|
||
```
|
||
|
||
Then add the code to `SUPPORTED_LOCALES` and `LOCALE_NAMES` in
|
||
`app/i18n.py`. The switcher picks it up on its own.
|
||
|
||
---
|
||
|
||
## 4. Checks
|
||
|
||
`tests/test_i18n.py` fails the build when:
|
||
|
||
- a compiled `.mo` is missing — otherwise the site silently serves English
|
||
everywhere, with nothing in the logs;
|
||
- a catalogue still contains an untranslated entry.
|
||
|
||
That second check is what keeps the migration honest: adding
|
||
`{{ _('...') }}` to a template without translating it turns the suite red.
|
||
|
||
---
|
||
|
||
## 5. State of the migration
|
||
|
||
**Done.** 631 strings, all translated, in both catalogues. The suite fails
|
||
if that stops being true.
|
||
|
||
Covered: navigation and shared layout, login, the five error pages, every
|
||
page template, every flash message, and every validation message.
|
||
|
||
**Flash messages are done too** — 198 calls across every route module —
|
||
along with the validation messages of `app/validators.py`, which use
|
||
`lazy_gettext` because schema fields are built at import time.
|
||
|
||
### What is not covered yet
|
||
|
||
**Model constants** — `ESPORT_GAMES`, `GAME_POSITIONS` — are data, not
|
||
interface. Game names stay as they are; positions such as "Support" or
|
||
"Duelist" are the terms players use in English and are deliberately left
|
||
alone.
|
||
|
||
### Two traps met during this migration
|
||
|
||
**HTML entities are not text.** `×`, used as a close-button label,
|
||
was marked by the automated pass. Jinja escaped it to `&times;`, so
|
||
the button would have shown the literal text `×` instead of ×.
|
||
Anything that is markup rather than prose must stay out of `_()`.
|
||
|
||
**Strings inside `<script>` blocks.** Seven remain, in `match_form.html` and
|
||
`one_on_one.html`. They work, but they are
|
||
fragile: Jinja escapes `& < > " '` inside a script block, and those
|
||
entities are *not* decoded there — a translation containing an apostrophe
|
||
would land in the JavaScript string as `'`. The French wording uses
|
||
typographic apostrophes (’), which are untouched, so the current strings
|
||
are safe. Anything added there should be passed through a `data-`
|
||
attribute or a `<script type="application/json">` block instead.
|
||
|
||
**The language choice must survive `session.clear()`.** Both login and
|
||
logout wipe the session — login to prevent session fixation, logout to end
|
||
it. The locale is a display preference, not state belonging to the account,
|
||
and was being discarded with the rest: someone who read the login page in
|
||
English was dropped straight back into French on signing in. Both call
|
||
sites now carry `locale` across explicitly, alongside `csrf_token`.
|