feat(i18n): serve every page in 30 languages
The interface ships translated; page content stays English until a
LibreTranslate instance fills app/i18n/content/ through make i18n. A string
without a catalogue entry falls back to its English source, so a half-filled
catalogue degrades instead of breaking.
Translation runs after ConfigurationResolver.resolve_links(), on a copy.
resolve_links matches by the `name` field, so translating it beforehand
would break every `link:` reference in the configuration.
negotiate() normalises to the primary subtag itself. Werkzeug's best_match
returns an exact match before it considers a primary-tag fallback, so the
Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries
Vary: Accept-Language, without which a shared cache pins the first
visitor's language for everyone.
The route rule lists the known codes as a converter argument. A bare
"/<lang>/" answers /robots.txt and /favicon.ico with a permanently
cacheable 308 to their trailing-slash form.
Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30
hreflang alternates. Those are the first external URLs in this app:
ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim
http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted
because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets
Flask reject a forged Host outright.
Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2
template interpolated configuration raw. Enabling it changes two lines of
the shipped page, both an apostrophe.
read_catalog degrades an unreadable catalogue to English rather than
serving a 500, and drops non-string entries that would otherwise render as
"42". i18n_sync writes atomically, never overwrites an existing entry,
refuses to touch a catalogue it could not parse, and leaves the file alone
when a run translated nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:02:35 +02:00
|
|
|
"""Language negotiation and translation of the resolved configuration tree.
|
|
|
|
|
|
|
|
|
|
Translation is catalogue-driven: a string is replaced only when the target
|
|
|
|
|
language's catalogue holds an entry for the exact English source string.
|
|
|
|
|
Anything unknown falls through to English, so a partially filled catalogue
|
|
|
|
|
degrades instead of breaking.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
feat(i18n): offer every ISO 639-1 language
The table was thirty languages typed by hand. It is now generated:
utils/generate_languages.py takes the 184 alpha-2 codes from pycountry,
the display names from CLDR through babel, and the writing direction from
CLDR character order. 159 languages carry their endonym; the remaining 25
have no CLDR entry and carry their English ISO name.
That corrects the right-to-left set, which had four entries and needs ten
— dv, ks, ps, sd, ug and yi were simply missed.
Only 29 languages ship an interface catalogue, so the other 155 render in
English until one is filled. make i18n-ui fills app/i18n/ui/ for them, and
make i18n now covers the interface strings as well; neither asks for a
string a shipped catalogue already answers, so hand-written entries stay.
184 entries do not fit on a screen, so the language menu scrolls inside
itself. overscroll-behavior keeps the page behind it from moving once the
list reaches its end.
babel and pycountry are dev dependencies: the generator needs them, the
application does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:08:28 +02:00
|
|
|
try:
|
|
|
|
|
from app.utils.languages import LANGUAGES, RTL_LANGUAGES
|
|
|
|
|
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
|
|
|
|
from utils.languages import LANGUAGES, RTL_LANGUAGES
|
|
|
|
|
|
feat(i18n): serve every page in 30 languages
The interface ships translated; page content stays English until a
LibreTranslate instance fills app/i18n/content/ through make i18n. A string
without a catalogue entry falls back to its English source, so a half-filled
catalogue degrades instead of breaking.
Translation runs after ConfigurationResolver.resolve_links(), on a copy.
resolve_links matches by the `name` field, so translating it beforehand
would break every `link:` reference in the configuration.
negotiate() normalises to the primary subtag itself. Werkzeug's best_match
returns an exact match before it considers a primary-tag fallback, so the
Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries
Vary: Accept-Language, without which a shared cache pins the first
visitor's language for everyone.
The route rule lists the known codes as a converter argument. A bare
"/<lang>/" answers /robots.txt and /favicon.ico with a permanently
cacheable 308 to their trailing-slash form.
Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30
hreflang alternates. Those are the first external URLs in this app:
ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim
http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted
because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets
Flask reject a forged Host outright.
Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2
template interpolated configuration raw. Enabling it changes two lines of
the shipped page, both an apostrophe.
read_catalog degrades an unreadable catalogue to English rather than
serving a 500, and drops non-string entries that would otherwise render as
"42". i18n_sync writes atomically, never overwrites an existing entry,
refuses to touch a catalogue it could not parse, and leaves the file alone
when a run translated nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:02:35 +02:00
|
|
|
I18N_DIR = Path(__file__).resolve().parent.parent / "i18n"
|
|
|
|
|
UI_DIR = I18N_DIR / "ui"
|
|
|
|
|
CONTENT_DIR = I18N_DIR / "content"
|
|
|
|
|
|
|
|
|
|
SOURCE_LANGUAGE = "en"
|
|
|
|
|
|
|
|
|
|
|
feat(i18n): translate labels, and keep brand names out of it
name and title were translated at render time but never machine-filled,
on the grounds that no backend tells the menu label "Pictures" from the
brand "Mastodon". That left the visible half of a card in English. They
are filled now, and the two key sets collapse into one.
The brands need somewhere to be named instead. app/i18n/keep.txt lists
them, one per line, and every entry is stored as itself in every target
language: no request, and never over an entry written by hand. --keep adds
one-off strings, --keep-file points elsewhere.
The shipped list holds the 43 product names that appear as name: or title:
in config.sample.yaml. Generic labels — Pictures, Imprint, Settings,
Certificates — are deliberately absent, and so are Cybermaster, Polymath
and Yachtmaster, which read as brand or as job title depending on who is
asking.
Two of these behaviours first shipped unguarded. A test that protected a
string and asserted the hand-written value survived passed either way,
because a run where nothing is missing reports "complete" and never
writes; and nothing exercised main(), so the keep file could stop being
read without a failure. Both are covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 17:18:33 +02:00
|
|
|
TRANSLATABLE_KEYS = frozenset(
|
|
|
|
|
{"description", "info", "name", "subtitel", "text", "title", "warning"}
|
|
|
|
|
)
|
feat(i18n): serve every page in 30 languages
The interface ships translated; page content stays English until a
LibreTranslate instance fills app/i18n/content/ through make i18n. A string
without a catalogue entry falls back to its English source, so a half-filled
catalogue degrades instead of breaking.
Translation runs after ConfigurationResolver.resolve_links(), on a copy.
resolve_links matches by the `name` field, so translating it beforehand
would break every `link:` reference in the configuration.
negotiate() normalises to the primary subtag itself. Werkzeug's best_match
returns an exact match before it considers a primary-tag fallback, so the
Chrome default `de-DE,en;q=0.8` resolves to English there. "/" carries
Vary: Accept-Language, without which a shared cache pins the first
visitor's language for everyone.
The route rule lists the known codes as a converter argument. A bare
"/<lang>/" answers /robots.txt and /favicon.ico with a permanently
cacheable 308 to their trailing-slash form.
Templates gain lang, dir, the RTL stylesheet, a canonical URL and 30
hreflang alternates. Those are the first external URLs in this app:
ProxyFix takes the scheme from X-Forwarded-Proto so they do not claim
http:// behind a TLS-terminating proxy, X-Forwarded-Host stays untrusted
because nginx passes a client-supplied one through, and TRUSTED_HOSTS lets
Flask reject a forged Host outright.
Flask only autoescapes .html/.htm/.xml/.xhtml/.svg, so every *.html.j2
template interpolated configuration raw. Enabling it changes two lines of
the shipped page, both an apostrophe.
read_catalog degrades an unreadable catalogue to English rather than
serving a 500, and drops non-string entries that would otherwise render as
"42". i18n_sync writes atomically, never overwrites an existing entry,
refuses to touch a catalogue it could not parse, and leaves the file alone
when a run translated nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:02:35 +02:00
|
|
|
|
|
|
|
|
UI_STRINGS = (
|
|
|
|
|
"Alternatives",
|
|
|
|
|
"Close",
|
|
|
|
|
"Copy",
|
|
|
|
|
"Identifier copied to clipboard!",
|
|
|
|
|
"Imprint",
|
|
|
|
|
"Information",
|
|
|
|
|
"Language",
|
|
|
|
|
"Open",
|
|
|
|
|
"Open Link",
|
|
|
|
|
"Options",
|
|
|
|
|
"Warning",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_catalogs: dict[str, dict[str, str]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def direction(code):
|
|
|
|
|
"""Return the writing direction of ``code`` as an HTML ``dir`` value."""
|
|
|
|
|
return "rtl" if code in RTL_LANGUAGES else "ltr"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_catalog(path):
|
|
|
|
|
"""Return the catalogue at ``path``, or an empty one if it is unusable.
|
|
|
|
|
|
|
|
|
|
Catalogues are hand-edited and machine-written, so a stray character must
|
|
|
|
|
degrade that language to English rather than take every page down with a
|
|
|
|
|
parse error. Non-string entries are dropped for the same reason: they would
|
|
|
|
|
otherwise reach the templates and render as ``42`` or ``null``.
|
|
|
|
|
"""
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return {}
|
|
|
|
|
try:
|
|
|
|
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
|
|
|
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
|
|
|
logging.warning("Ignoring unreadable translation catalogue: %s", path)
|
|
|
|
|
return {}
|
|
|
|
|
if not isinstance(loaded, dict):
|
|
|
|
|
logging.warning(
|
|
|
|
|
"Ignoring translation catalogue that is not a mapping: %s", path
|
|
|
|
|
)
|
|
|
|
|
return {}
|
|
|
|
|
return {
|
|
|
|
|
key: value
|
|
|
|
|
for key, value in loaded.items()
|
|
|
|
|
if isinstance(key, str) and isinstance(value, str)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clear_catalogs():
|
|
|
|
|
"""Drop the memoized catalogues so edited files are picked up."""
|
|
|
|
|
_catalogs.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def catalog(code):
|
|
|
|
|
"""Return the merged UI and content catalogue for ``code``."""
|
|
|
|
|
if code not in _catalogs:
|
|
|
|
|
_catalogs[code] = {
|
|
|
|
|
**read_catalog(UI_DIR / f"{code}.yaml"),
|
|
|
|
|
**read_catalog(CONTENT_DIR / f"{code}.yaml"),
|
|
|
|
|
}
|
|
|
|
|
return _catalogs[code]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def negotiate(accepted, default=SOURCE_LANGUAGE):
|
|
|
|
|
"""Pick the best supported language from ``Accept-Language`` pairs.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
accepted: iterable of ``(tag, quality)`` as produced by
|
|
|
|
|
``flask.request.accept_languages``.
|
|
|
|
|
default: language returned when no tag is supported.
|
|
|
|
|
|
|
|
|
|
Werkzeug's own ``best_match`` returns an exact match before it considers
|
|
|
|
|
primary-tag fallbacks, so ``de-DE,en;q=0.8`` resolves to English. Matching
|
|
|
|
|
on the primary subtag up front avoids that.
|
|
|
|
|
"""
|
|
|
|
|
best, best_quality = default, 0.0
|
|
|
|
|
for tag, quality in accepted:
|
|
|
|
|
code = tag.replace("_", "-").split("-")[0].lower()
|
|
|
|
|
if code in LANGUAGES and quality > best_quality:
|
|
|
|
|
best, best_quality = code, quality
|
|
|
|
|
return best
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def translate_tree(node, code, key=None):
|
|
|
|
|
"""Return a copy of ``node`` with translatable leaves swapped for ``code``.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
node: the resolved configuration tree, or any subtree of it.
|
|
|
|
|
code: target language code.
|
|
|
|
|
key: the mapping key ``node`` was reached through.
|
|
|
|
|
"""
|
|
|
|
|
if isinstance(node, dict):
|
|
|
|
|
return {name: translate_tree(value, code, name) for name, value in node.items()}
|
|
|
|
|
if isinstance(node, list):
|
|
|
|
|
return [translate_tree(item, code, key) for item in node]
|
|
|
|
|
if isinstance(node, str) and key in TRANSLATABLE_KEYS:
|
|
|
|
|
return catalog(code).get(node, node)
|
|
|
|
|
return node
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ui_strings(code):
|
|
|
|
|
"""Return the interface strings for ``code``, keyed by their English source."""
|
|
|
|
|
entries = catalog(code)
|
|
|
|
|
return {source: entries.get(source, source) for source in UI_STRINGS}
|