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>
This commit is contained in:
2026-08-22 01:08:28 +02:00
parent 0c67f999f6
commit ef1c8ff09a
12 changed files with 413 additions and 76 deletions

View File

@@ -35,12 +35,12 @@ class TestNegotiate(unittest.TestCase):
class TestDirection(unittest.TestCase):
def test_every_right_to_left_language_is_marked(self):
for code in ("ar", "fa", "he", "ur"):
for code in ("ar", "dv", "fa", "he", "ks", "ps", "sd", "ug", "ur", "yi"):
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"}:
for code in set(i18n.LANGUAGES) - set(i18n.RTL_LANGUAGES):
with self.subTest(code=code):
self.assertEqual(i18n.direction(code), "ltr")
@@ -157,9 +157,17 @@ class TestCatalogMerge(unittest.TestCase):
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_iso_639_1_language_is_offered(self):
self.assertEqual(len(i18n.LANGUAGES), 184)
self.assertEqual(i18n.SOURCE_LANGUAGE, next(iter(i18n.LANGUAGES)))
self.assertGreaterEqual(
set(i18n.LANGUAGES), {"en", "de", "zh", "ar", "he", "vo", "za", "cu"}
)
def test_every_display_name_is_filled_in(self):
blank = [code for code, name in i18n.LANGUAGES.items() if not name.strip()]
self.assertFalse(blank, f"No display name for: {blank}")
def test_every_code_is_usable_in_the_route_converter(self):
offenders = [
@@ -168,24 +176,21 @@ class TestShippedCatalogs(unittest.TestCase):
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()
def test_every_shipped_catalog_names_a_known_language(self):
stray = [
path.stem
for path in i18n.UI_DIR.glob("*.yaml")
if path.stem not in i18n.LANGUAGES or path.stem == i18n.SOURCE_LANGUAGE
]
self.assertFalse(missing, f"No UI catalogue for: {missing}")
self.assertFalse(stray, f"Catalogue for an unknown language: {stray}")
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"
for path in sorted(i18n.UI_DIR.glob("*.yaml")):
code = path.stem
entries = yaml.safe_load(path.read_text(encoding="utf-8"))
if set(entries) != expected:
mismatched[code] = {

View File

@@ -224,7 +224,7 @@ class TestSync(unittest.TestCase):
session = Mock(get=Mock(return_value=listing), post=Mock(return_value=answer))
with patch.object(i18n_sync.requests, "Session", return_value=session):
i18n_sync.sync("http://lt", "", set(sources), ["de"])
i18n_sync.sync("http://lt", "", set(sources), ["de"], self.directory)
return session
def test_missing_entries_are_written(self):
@@ -267,13 +267,26 @@ class TestSync(unittest.TestCase):
self.assertEqual(self.path.read_text(encoding="utf-8"), original)
def test_the_content_directory_is_created(self):
def test_a_string_the_shipped_catalog_covers_is_not_requested(self):
ui = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, ui, True)
self.addCleanup(setattr, i18n, "UI_DIR", i18n.UI_DIR)
i18n.UI_DIR = ui
(ui / "de.yaml").write_text("Close: Schliessen\n", encoding="utf-8")
session = self._run(["Close", "Hello"])
self.assertEqual(session.post.call_count, 1)
self.assertNotIn("Close", yaml.safe_load(self.path.read_text(encoding="utf-8")))
def test_the_catalog_directory_is_created(self):
nested = self.directory / "content"
i18n.CONTENT_DIR = nested
self.directory = nested
self.path = nested / "de.yaml"
self._run(["Hello"])
self.assertTrue((nested / "de.yaml").is_file())
self.assertTrue(self.path.is_file())
if __name__ == "__main__":