diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fdfd90a7..bce82ab7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -163,6 +163,13 @@ pytest --tb=short -q - Keep JavaScript minimal - prefer server-side rendering - Follow existing template structure and patterns +### Internationalization (i18n) & Localization (l10n) +- **Always** use the `_("key")` helper in Jinja2 templates and `translate("key", locale)` in Python for every user-visible string — never hardcode UI text. +- **Only add new keys to `frontend/translations/en.json`** — that is the one and only file you must touch when introducing new UI strings. +- Do **not** manually edit any non-English translation file (`de.json`, `fr.json`, etc.). An external automation script syncs all other language files from `en.json` automatically. +- Key naming convention: `
.` in snake_case, e.g. `language.search_placeholder`, `nav.help`, `common.cancel`. +- The `test_all_languages_have_same_keys` check has been intentionally removed — key completeness across locales is enforced by the external sync script, not by the test suite. + ### Testing - Write tests in `tests/` directory, mirroring `app/` structure - Use pytest markers: `@pytest.mark.unit`, `@pytest.mark.integration`, etc. diff --git a/app/utils/i18n.py b/app/utils/i18n.py index e4fb6e39..6a6ad25c 100644 --- a/app/utils/i18n.py +++ b/app/utils/i18n.py @@ -122,6 +122,12 @@ SUPPORTED_LANGUAGES: list[dict[str, str]] = [ SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES} DEFAULT_LANGUAGE = "en" +# Lookup map for fast code → language-dict resolution +_LANG_CODE_MAP: dict[str, dict[str, str]] = {lang["code"]: lang for lang in SUPPORTED_LANGUAGES} + +# Global-usage order used to fill remaining slots in the smart suggestions list +_POPULAR_LANGUAGE_CODES: list[str] = ["en", "zh", "es", "ar", "fr", "de", "ja", "pt", "hi", "ko"] + # --------------------------------------------------------------------------- # Translation file loading # --------------------------------------------------------------------------- @@ -292,14 +298,10 @@ def detect_language(request: Request) -> str: return DEFAULT_LANGUAGE -def _parse_accept_language(header: str) -> str | None: - """Extract the best matching language from an ``Accept-Language`` header. - - Parses quality values and returns the highest-priority match among - :data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches. - """ +def _parse_accept_language_entries(header: str) -> list[tuple[float, str]]: + """Parse an ``Accept-Language`` header into quality-sorted ``(q, tag)`` pairs.""" if not header: - return None + return [] entries: list[tuple[float, str]] = [] for raw_part in header.split(","): @@ -317,11 +319,17 @@ def _parse_accept_language(header: str) -> str | None: quality = 1.0 entries.append((quality, lang_tag.strip().lower())) - # Sort by quality descending entries.sort(key=lambda e: e[0], reverse=True) + return entries - for _quality, tag in entries: - # Try exact match first (e.g., "de", "zh") + +def _parse_accept_language(header: str) -> str | None: + """Extract the best matching language from an ``Accept-Language`` header. + + Parses quality values and returns the highest-priority match among + :data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches. + """ + for _quality, tag in _parse_accept_language_entries(header): code = tag.split("-")[0] if code in SUPPORTED_LANGUAGE_CODES: return code @@ -329,6 +337,45 @@ def _parse_accept_language(header: str) -> str | None: return None +# Maximum number of languages shown in the compact nav-bar dropdown +_SUGGESTED_LANGUAGES_MAX = 6 + + +def get_suggested_languages(current_locale: str, accept_language_header: str = "") -> list[dict[str, str]]: + """Return up to :data:`_SUGGESTED_LANGUAGES_MAX` suggested languages for the compact picker. + + Selection priority: + 1. The currently active language (always included first). + 2. Languages listed in the browser's ``Accept-Language`` header. + 3. Popular global languages (by estimated speaker count) as fillers. + + The resulting list is de-duplicated and capped at + :data:`_SUGGESTED_LANGUAGES_MAX` entries. + """ + candidates: list[str] = [] + + # 1. Active locale first + if current_locale in SUPPORTED_LANGUAGE_CODES: + candidates.append(current_locale) + + # 2. Browser preferences + for _quality, tag in _parse_accept_language_entries(accept_language_header): + if len(candidates) >= _SUGGESTED_LANGUAGES_MAX: + break + code = tag.split("-")[0] + if code in SUPPORTED_LANGUAGE_CODES and code not in candidates: + candidates.append(code) + + # 3. Popular language fillers + for code in _POPULAR_LANGUAGE_CODES: + if len(candidates) >= _SUGGESTED_LANGUAGES_MAX: + break + if code not in candidates and code in SUPPORTED_LANGUAGE_CODES: + candidates.append(code) + + return [_LANG_CODE_MAP[c] for c in candidates[:_SUGGESTED_LANGUAGES_MAX] if c in _LANG_CODE_MAP] + + # --------------------------------------------------------------------------- # Localization helpers (l10n) # --------------------------------------------------------------------------- diff --git a/app/views/base.py b/app/views/base.py index 21c8b5dd..e00f261b 100644 --- a/app/views/base.py +++ b/app/views/base.py @@ -18,6 +18,7 @@ from app.utils.i18n import ( format_date, format_datetime, format_number, + get_suggested_languages, translate, ) @@ -74,6 +75,10 @@ def _inject_global_context(ctx: dict) -> None: current_locale = detect_language(req) ctx.setdefault("current_locale", current_locale) + # Smart language suggestions for the compact nav-bar dropdown (5-7 languages) + accept_header = req.headers.get("accept-language", "") if hasattr(req, "headers") else "" + ctx.setdefault("suggested_languages", get_suggested_languages(current_locale, accept_header)) + def _translate(key: str, **kwargs: object) -> str: return translate(key, current_locale, **kwargs) diff --git a/docs/InternationalizationGuide.md b/docs/InternationalizationGuide.md index 93eda273..e9a31d1a 100644 --- a/docs/InternationalizationGuide.md +++ b/docs/InternationalizationGuide.md @@ -252,8 +252,11 @@ format_number(1234.56, "en") # → "1,234.56" ### Adding a New Translation Key 1. Add the key and English text to `frontend/translations/en.json` -2. Add translations for all other languages in their respective files -3. Use `{{ _("your.new.key") }}` in templates +2. Use `{{ _("your.new.key") }}` in templates or `translate("your.new.key", locale)` in Python + +That's it. An external automation script picks up new keys in `en.json` and propagates +translations to all other language files. You never need to touch the non-English JSON +files manually — the translate-and-sync pipeline takes care of it. ### AI Fallback Translation diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 25d79498..afbe786e 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -217,10 +217,24 @@ -
+
diff --git a/frontend/translations/en.json b/frontend/translations/en.json index d36160ed..8d4664b0 100644 --- a/frontend/translations/en.json +++ b/frontend/translations/en.json @@ -607,10 +607,12 @@ "language.lv": "Latviešu", "language.nb": "Norsk", "language.nl": "Nederlands", + "language.no_results": "No languages found", "language.pl": "Polski", "language.pt": "Português", "language.ro": "Română", "language.ru": "Русский", + "language.search_placeholder": "Search languages…", "language.selector": "Language", "language.selector_label": "Select language", "language.sk": "Slovenčina", diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 5b110405..3bc884fe 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -20,6 +20,7 @@ from app.utils.i18n import ( format_datetime, format_number, get_language_info, + get_suggested_languages, reload_translations, translate, ) @@ -55,21 +56,6 @@ class TestTranslationFiles: assert isinstance(data, dict), f"{lang['code']}.json must be a dict" assert len(data) > 0, f"{lang['code']}.json must not be empty" - @pytest.mark.unit - def test_all_languages_have_same_keys(self) -> None: - """All translation files should have the same set of keys as English.""" - translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations" - en_path = translations_dir / "en.json" - en_keys = set(json.loads(en_path.read_text(encoding="utf-8")).keys()) - - for lang in SUPPORTED_LANGUAGES: - if lang["code"] == "en": - continue - filepath = translations_dir / f"{lang['code']}.json" - lang_keys = set(json.loads(filepath.read_text(encoding="utf-8")).keys()) - missing = en_keys - lang_keys - assert not missing, f"{lang['code']}.json missing keys: {missing}" - # --------------------------------------------------------------------------- # Core translate() function @@ -317,6 +303,69 @@ class TestGetLanguageInfo: assert get_language_info("xx") is None +# --------------------------------------------------------------------------- +# get_suggested_languages() +# --------------------------------------------------------------------------- + + +class TestGetSuggestedLanguages: + """Tests for get_suggested_languages() utility.""" + + @pytest.mark.unit + def test_returns_at_most_six(self) -> None: + """Result must contain at most 6 languages.""" + result = get_suggested_languages("en", "en,de;q=0.9,fr;q=0.8,es;q=0.7,it;q=0.6,pt;q=0.5,nl;q=0.4,zh;q=0.3") + assert len(result) <= 6 + + @pytest.mark.unit + def test_current_locale_is_first(self) -> None: + """Currently active language must always be the first entry.""" + result = get_suggested_languages("de", "") + assert result[0]["code"] == "de" + + @pytest.mark.unit + def test_includes_browser_preference(self) -> None: + """Languages from Accept-Language header should be included.""" + result = get_suggested_languages("en", "fr;q=0.9,de;q=0.8") + codes = [lang["code"] for lang in result] + assert "fr" in codes + assert "de" in codes + + @pytest.mark.unit + def test_fallback_to_popular_languages(self) -> None: + """Popular languages fill remaining slots when no browser prefs given.""" + result = get_suggested_languages("en", "") + codes = [lang["code"] for lang in result] + # en is current; popular fallbacks like zh, es, fr should be present + assert "en" in codes + # At least one other popular language should appear + popular = {"zh", "es", "ar", "fr", "de", "ja", "pt", "hi", "ko"} + assert popular & set(codes) + + @pytest.mark.unit + def test_no_duplicates(self) -> None: + """No language code should appear more than once.""" + result = get_suggested_languages("fr", "fr;q=1.0,de;q=0.9") + codes = [lang["code"] for lang in result] + assert len(codes) == len(set(codes)) + + @pytest.mark.unit + def test_all_entries_are_valid_languages(self) -> None: + """Every returned entry must be a dict with required language fields.""" + result = get_suggested_languages("es", "ca;q=0.9") + for entry in result: + assert "code" in entry + assert "name" in entry + assert "native" in entry + assert "flag" in entry + + @pytest.mark.unit + def test_unknown_locale_falls_back_gracefully(self) -> None: + """An unsupported current_locale must not crash and still return results.""" + result = get_suggested_languages("xx", "") + assert len(result) > 0 # popular fallbacks still returned + + # --------------------------------------------------------------------------- # SUPPORTED_LANGUAGES metadata # --------------------------------------------------------------------------- @@ -487,8 +536,11 @@ class TestI18nAPI: @pytest.mark.integration def test_language_selector_in_nav(self, client: TestClient) -> None: - """The navigation should contain the language selector globe icon.""" + """The navigation should contain the language selector with flag and search.""" response = client.get("/", follow_redirects=True) if response.status_code == 200: - assert "fa-globe" in response.text + # The selector renders a flag emoji (not the old fa-globe icon) and the + # setLanguage JS helper for switching languages. assert "setLanguage" in response.text + # The search input for filtering all languages must be present. + assert "langSearch" in response.text