feat(i18n): add browser-language localization with Jinja2 _() and locale middleware
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Some checks failed
ci/woodpecker/pr/pipeline Pipeline failed
Add i18n support to the blog web UI with 4 languages (en/ru/fr/de),
80 translation keys, automatic Accept-Language detection, persistent
locale cookie, and a language switcher dropdown in the header.
- Infrastructure: TranslationService, translation dicts, convenience _()
- Presentation: locale middleware, /web/lang/{locale} switcher route
- Templates: all 9 templates use {{ _(key, current_locale) }}
- Tests: 26 tests across TranslationService, locale detection helpers
- Docs: TEST_MODEL.md and FEATURE_INFRASTRUCTURE.md updated with TC-UNIT-811-821
This commit is contained in:
72
app/presentation/web/locale.py
Normal file
72
app/presentation/web/locale.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Locale detection and management for i18n support.
|
||||
|
||||
This module provides locale detection from Accept-Language headers and cookies,
|
||||
following the same middleware pattern as the flash message system.
|
||||
"""
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.infrastructure.i18n.translator import DEFAULT_LOCALE, SUPPORTED_LOCALES
|
||||
|
||||
LOCALE_COOKIE_NAME = "locale"
|
||||
SUPPORTED_LOCALES_SET: frozenset[str] = SUPPORTED_LOCALES
|
||||
|
||||
|
||||
def _parse_accept_language(header: str) -> list[str]:
|
||||
"""Parse Accept-Language header into ordered list of locale codes.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language header value.
|
||||
|
||||
Returns:
|
||||
List of locale codes in preference order, with region subtags removed.
|
||||
"""
|
||||
if not header:
|
||||
return []
|
||||
|
||||
locales: list[str] = []
|
||||
for part in header.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
locale = part.split(";")[0].strip().split("-")[0]
|
||||
if locale:
|
||||
locales.append(locale)
|
||||
return locales
|
||||
|
||||
|
||||
def _get_best_locale(request: Request) -> str:
|
||||
"""Detect the best locale for the current request.
|
||||
|
||||
Priority order: cookie → Accept-Language header → default.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
|
||||
Returns:
|
||||
Best matching locale code, defaulting to ``en``.
|
||||
"""
|
||||
cookie_locale = request.cookies.get(LOCALE_COOKIE_NAME)
|
||||
if cookie_locale and cookie_locale in SUPPORTED_LOCALES_SET:
|
||||
return cookie_locale
|
||||
|
||||
accept_language = request.headers.get("accept-language", "")
|
||||
for lang in _parse_accept_language(accept_language):
|
||||
if lang in SUPPORTED_LOCALES_SET:
|
||||
return lang
|
||||
|
||||
return DEFAULT_LOCALE
|
||||
|
||||
|
||||
async def setup_locale_manager(request: Request) -> None:
|
||||
"""Set the detected locale on request state.
|
||||
|
||||
Called early in the request lifecycle so that route handlers and
|
||||
template rendering can access the current locale via
|
||||
``request.state.locale``.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
"""
|
||||
if not hasattr(request.state, "locale"):
|
||||
request.state.locale = _get_best_locale(request)
|
||||
Reference in New Issue
Block a user