mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-08-24 05:04:33 +00:00
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:
83
utils/generate_languages.py
Normal file
83
utils/generate_languages.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user