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

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Regenerate app/utils/languages.py from ISO 639-1 and CLDR.
Every ISO 639-1 alpha-2 code becomes an entry. The display name is the CLDR
endonym where CLDR knows the language, and the English ISO name for the
remainder. Writing direction comes from CLDR's character order.
Needs the dev extra: pip install -e ".[dev]"
"""
import subprocess
import sys
from pathlib import Path
import pycountry
from babel import Locale
from babel.localedata import locale_identifiers
TARGET = Path(__file__).resolve().parents[1] / "app" / "utils" / "languages.py"
HEADER = '''"""ISO 639-1 languages, their display names and writing direction.
Generated by utils/generate_languages.py — edit that script, not this file.
Display names are CLDR endonyms where CLDR covers the language and the English
ISO 639-1 name for the {fallback} codes it does not.
"""
LANGUAGES = {{
'''
FOOTER = """}}
RTL_LANGUAGES = frozenset({rtl})
"""
def collect():
"""Return (code -> display name) and the set of right-to-left codes."""
cldr = {code for code in locale_identifiers() if len(code) == 2 and code.isalpha()}
names, rtl, fallbacks = {}, set(), 0
for language in sorted(
pycountry.languages, key=lambda item: getattr(item, "alpha_2", "")
):
code = getattr(language, "alpha_2", None)
if not code:
continue
if code in cldr:
locale = Locale.parse(code)
names[code] = locale.get_display_name(code)
if locale.character_order == "right-to-left":
rtl.add(code)
else:
names[code] = language.name
fallbacks += 1
return names, rtl, fallbacks
def render(names, rtl, fallbacks):
"""Return the module source, English first and the rest by code."""
ordered = ["en"] + [code for code in sorted(names) if code != "en"]
body = "".join(f' "{code}": "{names[code]}",\n' for code in ordered)
listed = ", ".join(f'"{code}"' for code in sorted(rtl))
return (
HEADER.format(fallback=fallbacks) + body + FOOTER.format(rtl="{" + listed + "}")
)
def main():
names, rtl, fallbacks = collect()
TARGET.write_text(render(names, rtl, fallbacks), encoding="utf-8")
subprocess.run([sys.executable, "-m", "ruff", "format", str(TARGET)], check=False)
print(
f"{len(names)} languages, {len(rtl)} right-to-left, "
f"{fallbacks} without a CLDR endonym"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -144,9 +144,17 @@ def translate(session, url, api_key, text, target):
return translated
def sync(url, api_key, sources, targets):
"""Fill and write the content catalogue of every language in ``targets``."""
i18n.CONTENT_DIR.mkdir(parents=True, exist_ok=True)
def sync(url, api_key, sources, targets, directory):
"""Fill and write the catalogue of every language in ``targets``.
Args:
url: base URL of the LibreTranslate instance.
api_key: API key, or an empty string.
sources: English strings to translate.
targets: language codes to fill.
directory: catalogue directory to write into.
"""
directory.mkdir(parents=True, exist_ok=True)
session = requests.Session()
available = supported_languages(url, session)
@@ -155,12 +163,21 @@ def sync(url, api_key, sources, targets):
print(f"- {target}: not offered by {url}, skipped")
continue
path = i18n.CONTENT_DIR / f"{target}.yaml"
path = directory / f"{target}.yaml"
catalog = load_existing(path)
if catalog is None:
continue
missing = sorted(source for source in sources if source not in catalog)
shipped = (
{}
if directory == i18n.UI_DIR
else i18n.read_catalog(i18n.UI_DIR / f"{target}.yaml")
)
missing = sorted(
source
for source in sources
if source not in catalog and source not in shipped
)
if not missing:
print(f"- {target}: complete")
continue
@@ -201,6 +218,15 @@ def main(argv=None):
default=DEFAULT_CONFIG_PATH,
help=f"Configuration to read the prose from (default: {DEFAULT_CONFIG_PATH}).",
)
parser.add_argument(
"--catalog",
choices=("content", "ui"),
default="content",
help=(
"content: your configuration's prose, generated per deployment. "
"ui: the interface strings that ship with the project."
),
)
parser.add_argument(
"--languages",
nargs="+",
@@ -209,12 +235,18 @@ def main(argv=None):
)
args = parser.parse_args(argv)
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))
sources = collect_sources(config)
print(f"{len(sources)} translatable string(s) in {args.config}")
if args.catalog == "ui":
directory = i18n.UI_DIR
sources = set(i18n.UI_STRINGS)
print(f"{len(sources)} interface string(s)")
else:
directory = i18n.CONTENT_DIR
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))
sources = collect_sources(config) | set(i18n.UI_STRINGS)
print(f"{len(sources)} string(s) in {args.config} and the interface")
try:
sync(args.url.rstrip("/"), args.api_key, sources, args.languages)
sync(args.url.rstrip("/"), args.api_key, sources, args.languages, directory)
except BackendError as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1