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>
This commit is contained in:
2026-08-22 00:02:35 +02:00
parent a42930a699
commit 2a35b2910a
46 changed files with 1915 additions and 37 deletions

212
tests/unit/test_i18n.py Normal file
View File

@@ -0,0 +1,212 @@
import re
import shutil
import tempfile
import unittest
from pathlib import Path
import yaml
from app.utils import i18n
class TestNegotiate(unittest.TestCase):
def test_regional_tag_beats_a_lower_ranked_exact_match(self):
self.assertEqual(i18n.negotiate([("de-DE", 1.0), ("en", 0.8)]), "de")
def test_underscore_separated_tag_is_accepted(self):
self.assertEqual(i18n.negotiate([("pt_BR", 1.0)]), "pt")
def test_highest_quality_supported_tag_wins(self):
self.assertEqual(
i18n.negotiate([("xx", 1.0), ("fr", 0.9), ("es", 0.5)]),
"fr",
)
def test_unsupported_tags_fall_back_to_the_default(self):
self.assertEqual(i18n.negotiate([("xx", 1.0)]), "en")
self.assertEqual(i18n.negotiate([], default="de"), "de")
def test_a_refused_language_is_not_selected(self):
self.assertEqual(i18n.negotiate([("de", 0.0)]), "en")
def test_the_first_of_two_equal_tags_wins(self):
self.assertEqual(i18n.negotiate([("fr", 0.9), ("es", 0.9)]), "fr")
class TestDirection(unittest.TestCase):
def test_every_right_to_left_language_is_marked(self):
for code in ("ar", "fa", "he", "ur"):
with self.subTest(code=code):
self.assertEqual(i18n.direction(code), "rtl")
def test_other_languages_are_left_to_right(self):
for code in set(i18n.LANGUAGES) - {"ar", "fa", "he", "ur"}:
with self.subTest(code=code):
self.assertEqual(i18n.direction(code), "ltr")
class TestTranslateTree(unittest.TestCase):
def setUp(self):
self.addCleanup(i18n._catalogs.clear)
i18n._catalogs["xx"] = {"A card": "Eine Karte", "Pictures": "Bilder"}
def test_only_translatable_keys_are_replaced(self):
tree = {
"cards": [
{
"title": "Pictures",
"text": "A card",
"url": "A card",
"icon": {"class": "Pictures"},
}
]
}
translated = i18n.translate_tree(tree, "xx")
card = translated["cards"][0]
self.assertEqual(card["title"], "Bilder")
self.assertEqual(card["text"], "Eine Karte")
self.assertEqual(card["url"], "A card")
self.assertEqual(card["icon"]["class"], "Pictures")
def test_unknown_strings_keep_their_source_value(self):
translated = i18n.translate_tree({"description": "Untranslated"}, "xx")
self.assertEqual(translated["description"], "Untranslated")
def test_source_tree_is_left_untouched(self):
tree = {"name": "Pictures"}
i18n.translate_tree(tree, "xx")
self.assertEqual(tree["name"], "Pictures")
def test_non_string_leaves_survive(self):
tree = {"name": 1, "text": None, "info": True}
self.assertEqual(i18n.translate_tree(tree, "xx"), tree)
class TestReadCatalog(unittest.TestCase):
def setUp(self):
self.directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.directory, True)
self.path = self.directory / "de.yaml"
def _read(self, text):
self.path.write_text(text, encoding="utf-8")
with self.assertLogs(level="WARNING"):
return i18n.read_catalog(self.path)
def test_absent_file_is_an_empty_catalog(self):
self.assertEqual(i18n.read_catalog(self.directory / "missing.yaml"), {})
def test_unparsable_yaml_is_an_empty_catalog(self):
self.assertEqual(self._read("Close: [unclosed"), {})
def test_tab_indentation_is_an_empty_catalog(self):
self.assertEqual(self._read("a:\n\tb: 1"), {})
def test_non_mapping_document_is_an_empty_catalog(self):
self.assertEqual(self._read("- a\n- b"), {})
def test_a_non_utf8_catalog_is_an_empty_catalog(self):
self.path.write_bytes(b"\xffClose: Schliessen\n")
with self.assertLogs(level="WARNING"):
self.assertEqual(i18n.read_catalog(self.path), {})
def test_a_directory_at_the_catalog_path_is_an_empty_catalog(self):
(self.directory / "sub.yaml").mkdir()
with self.assertLogs(level="WARNING"):
self.assertEqual(i18n.read_catalog(self.directory / "sub.yaml"), {})
def test_non_string_entries_are_dropped(self):
self.path.write_text(
"Close: 42\nOpen:\nCopy: yes\nImprint: Impressum\n", encoding="utf-8"
)
self.assertEqual(i18n.read_catalog(self.path), {"Imprint": "Impressum"})
class TestCatalogMerge(unittest.TestCase):
def setUp(self):
directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, directory, True)
(directory / "ui").mkdir()
(directory / "content").mkdir()
self.addCleanup(setattr, i18n, "UI_DIR", i18n.UI_DIR)
self.addCleanup(setattr, i18n, "CONTENT_DIR", i18n.CONTENT_DIR)
self.addCleanup(i18n.clear_catalogs)
i18n.UI_DIR = directory / "ui"
i18n.CONTENT_DIR = directory / "content"
i18n.clear_catalogs()
def test_the_content_catalog_overrides_the_shipped_interface_string(self):
(i18n.UI_DIR / "de.yaml").write_text(
"Close: Schliessen\nImprint: Impressum\n", encoding="utf-8"
)
(i18n.CONTENT_DIR / "de.yaml").write_text("Close: Zumachen\n", encoding="utf-8")
self.assertEqual(
i18n.catalog("de"), {"Close": "Zumachen", "Imprint": "Impressum"}
)
class TestShippedCatalogs(unittest.TestCase):
def test_thirty_languages_are_offered(self):
self.assertEqual(len(i18n.LANGUAGES), 30)
self.assertIn(i18n.SOURCE_LANGUAGE, i18n.LANGUAGES)
def test_every_code_is_usable_in_the_route_converter(self):
offenders = [
code for code in i18n.LANGUAGES if not re.fullmatch(r"[a-z]+", code)
]
self.assertFalse(offenders, f"Not usable in the route converter: {offenders}")
def test_every_language_but_the_source_ships_a_ui_catalog(self):
missing = [
code
for code in i18n.LANGUAGES
if code != i18n.SOURCE_LANGUAGE
and not (i18n.UI_DIR / f"{code}.yaml").is_file()
]
self.assertFalse(missing, f"No UI catalogue for: {missing}")
def test_ui_catalogs_cover_exactly_the_interface_strings(self):
expected = set(i18n.UI_STRINGS)
mismatched = {}
for code in i18n.LANGUAGES:
if code == i18n.SOURCE_LANGUAGE:
continue
path = i18n.UI_DIR / f"{code}.yaml"
entries = yaml.safe_load(path.read_text(encoding="utf-8"))
if set(entries) != expected:
mismatched[code] = {
"missing": sorted(expected - set(entries)),
"unexpected": sorted(set(entries) - expected),
}
self.assertFalse(mismatched, f"UI catalogues out of sync: {mismatched}")
def test_ui_strings_of_the_source_language_are_the_source(self):
self.assertEqual(
i18n.ui_strings("en"),
{source: source for source in i18n.UI_STRINGS},
)
def test_ui_strings_are_translated_for_a_shipped_language(self):
strings = i18n.ui_strings("de")
self.assertEqual(strings["Close"], "Schließen")
self.assertEqual(strings["Imprint"], "Impressum")
if __name__ == "__main__":
unittest.main()