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:
9
app/infrastructure/i18n/__init__.py
Normal file
9
app/infrastructure/i18n/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Internationalization support for the blog application.
|
||||
|
||||
This package provides translation dictionaries and the translation service
|
||||
for localizing the web UI into multiple languages.
|
||||
"""
|
||||
|
||||
from app.infrastructure.i18n.translator import SUPPORTED_LOCALES, TranslationService, _
|
||||
|
||||
__all__ = ["SUPPORTED_LOCALES", "TranslationService", "_"]
|
||||
337
app/infrastructure/i18n/translations.py
Normal file
337
app/infrastructure/i18n/translations.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""Translation dictionaries for i18n support.
|
||||
|
||||
This module provides translation dictionaries for all supported locales.
|
||||
Translations are organized by feature area for maintainability.
|
||||
Keys use dot-separated namespacing to avoid collisions.
|
||||
"""
|
||||
|
||||
TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
"nav.home": "Home",
|
||||
"nav.posts": "Posts",
|
||||
"nav.about": "About",
|
||||
"header.logo": "Blog",
|
||||
"header.profile": "Profile",
|
||||
"header.new_post": "New Post",
|
||||
"header.sign_out": "Sign Out",
|
||||
"header.sign_in": "Sign In",
|
||||
"header.toggle_menu": "Toggle menu",
|
||||
"header.toggle_theme": "Toggle dark mode",
|
||||
"header.lang_switcher": "Language",
|
||||
"footer.copyright": "© 2026 Blog. All rights reserved.",
|
||||
"footer.about": "About",
|
||||
"footer.privacy": "Privacy",
|
||||
"footer.terms": "Terms",
|
||||
"footer.api": "API",
|
||||
"home.title": "Blog - Home",
|
||||
"home.meta_description": "Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.",
|
||||
"home.meta_keywords": "blog, articles, posts, writing, fastapi, python",
|
||||
"home.page_title": "Latest Posts",
|
||||
"home.page_subtitle": "Discover stories, thinking, and expertise from writers on any topic.",
|
||||
"home.write_post": "Write a Post",
|
||||
"home.status_published": "Published",
|
||||
"home.status_draft": "Draft",
|
||||
"home.read_more": "Read more",
|
||||
"home.pagination_previous": "Previous",
|
||||
"home.pagination_next": "Next",
|
||||
"home.empty_title": "No posts yet",
|
||||
"home.empty_description": "Be the first to write a post!",
|
||||
"home.empty_action": "Create your first post",
|
||||
"post.status_published": "Published",
|
||||
"post.status_draft": "Draft",
|
||||
"post.back_to_posts": "Back to posts",
|
||||
"post.edit": "Edit",
|
||||
"post.delete": "Delete",
|
||||
"post.delete_confirm": "Are you sure you want to delete this post?",
|
||||
"post_form.title_edit": "Edit Post",
|
||||
"post_form.title_new": "New Post",
|
||||
"post_form.page_title_edit": "Edit Post",
|
||||
"post_form.page_title_new": "Create New Post",
|
||||
"post_form.label_title": "Title",
|
||||
"post_form.placeholder_title": "Enter post title",
|
||||
"post_form.hint_title": "A catchy title for your post",
|
||||
"post_form.label_content": "Content",
|
||||
"post_form.placeholder_content": "Write your post content here...",
|
||||
"post_form.hint_content": "The main content of your post. Markdown is supported.",
|
||||
"post_form.label_tags": "Tags",
|
||||
"post_form.placeholder_tags": "python, fastapi, tutorial",
|
||||
"post_form.hint_tags": "Comma-separated list of tags",
|
||||
"post_form.cancel": "Cancel",
|
||||
"post_form.save_draft": "Save as Draft",
|
||||
"post_form.update_post": "Update Post",
|
||||
"post_form.publish_post": "Publish Post",
|
||||
"profile.title": "User Profile",
|
||||
"profile.email": "Email:",
|
||||
"profile.not_provided": "Not provided",
|
||||
"profile.user_id": "User ID:",
|
||||
"profile.name": "Name:",
|
||||
"profile.back_home": "Back to Home",
|
||||
"profile.new_post": "New Post",
|
||||
"about.title": "About",
|
||||
"about.page_title": "About",
|
||||
"about.description": "A modern blog built with FastAPI and Domain-Driven Design architecture.",
|
||||
"about.signed_in": "Signed in as {username}.",
|
||||
"about.browsing_guest": "You are browsing as a guest.",
|
||||
"about.back_home": "Back to Home",
|
||||
"flash.post_published": "Post published successfully!",
|
||||
"flash.post_saved_draft": "Post saved as draft!",
|
||||
"flash.post_updated": "Post updated successfully!",
|
||||
"flash.post_deleted": "Post deleted successfully!",
|
||||
"flash.post_not_found": "Post not found.",
|
||||
"base.close_message": "Close message",
|
||||
"base.default_title": "Blog",
|
||||
"base.meta_description": "Blog - A modern blogging platform built with FastAPI",
|
||||
"base.meta_keywords": "blog, articles, posts, writing",
|
||||
"base.meta_author": "Blog Team",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Русский",
|
||||
"lang.fr": "Français",
|
||||
"lang.de": "Deutsch",
|
||||
},
|
||||
"ru": {
|
||||
"nav.home": "Главная",
|
||||
"nav.posts": "Статьи",
|
||||
"nav.about": "О нас",
|
||||
"header.logo": "Блог",
|
||||
"header.profile": "Профиль",
|
||||
"header.new_post": "Новая статья",
|
||||
"header.sign_out": "Выйти",
|
||||
"header.sign_in": "Войти",
|
||||
"header.toggle_menu": "Открыть меню",
|
||||
"header.toggle_theme": "Сменить тему",
|
||||
"header.lang_switcher": "Язык",
|
||||
"footer.copyright": "© 2026 Блог. Все права защищены.",
|
||||
"footer.about": "О нас",
|
||||
"footer.privacy": "Конфиденциальность",
|
||||
"footer.terms": "Условия",
|
||||
"footer.api": "API",
|
||||
"home.title": "Блог — Главная",
|
||||
"home.meta_description": "Откройте для себя истории, мысли и опыт авторов на любую тему. Современный блог на FastAPI.",
|
||||
"home.meta_keywords": "блог, статьи, посты, fastapi, python",
|
||||
"home.page_title": "Последние статьи",
|
||||
"home.page_subtitle": "Откройте для себя истории, мысли и опыт авторов на любую тему.",
|
||||
"home.write_post": "Написать статью",
|
||||
"home.status_published": "Опубликовано",
|
||||
"home.status_draft": "Черновик",
|
||||
"home.read_more": "Читать далее",
|
||||
"home.pagination_previous": "Назад",
|
||||
"home.pagination_next": "Вперёд",
|
||||
"home.empty_title": "Статей пока нет",
|
||||
"home.empty_description": "Будьте первым, кто напишет статью!",
|
||||
"home.empty_action": "Создать первую статью",
|
||||
"post.status_published": "Опубликовано",
|
||||
"post.status_draft": "Черновик",
|
||||
"post.back_to_posts": "К списку статей",
|
||||
"post.edit": "Редактировать",
|
||||
"post.delete": "Удалить",
|
||||
"post.delete_confirm": "Вы уверены, что хотите удалить эту статью?",
|
||||
"post_form.title_edit": "Редактировать статью",
|
||||
"post_form.title_new": "Новая статья",
|
||||
"post_form.page_title_edit": "Редактировать статью",
|
||||
"post_form.page_title_new": "Создать новую статью",
|
||||
"post_form.label_title": "Заголовок",
|
||||
"post_form.placeholder_title": "Введите заголовок статьи",
|
||||
"post_form.hint_title": "Запоминающийся заголовок для вашей статьи",
|
||||
"post_form.label_content": "Содержание",
|
||||
"post_form.placeholder_content": "Напишите содержание статьи здесь...",
|
||||
"post_form.hint_content": "Основное содержание вашей статьи. Поддерживается Markdown.",
|
||||
"post_form.label_tags": "Теги",
|
||||
"post_form.placeholder_tags": "python, fastapi, tutorial",
|
||||
"post_form.hint_tags": "Список тегов через запятую",
|
||||
"post_form.cancel": "Отмена",
|
||||
"post_form.save_draft": "Сохранить черновик",
|
||||
"post_form.update_post": "Обновить статью",
|
||||
"post_form.publish_post": "Опубликовать статью",
|
||||
"profile.title": "Профиль пользователя",
|
||||
"profile.email": "Email:",
|
||||
"profile.not_provided": "Не указан",
|
||||
"profile.user_id": "ID пользователя:",
|
||||
"profile.name": "Имя:",
|
||||
"profile.back_home": "На главную",
|
||||
"profile.new_post": "Новая статья",
|
||||
"about.title": "О нас",
|
||||
"about.page_title": "О нас",
|
||||
"about.description": "Современный блог на FastAPI с архитектурой Domain-Driven Design.",
|
||||
"about.signed_in": "Вы вошли как {username}.",
|
||||
"about.browsing_guest": "Вы просматриваете как гость.",
|
||||
"about.back_home": "На главную",
|
||||
"flash.post_published": "Статья успешно опубликована!",
|
||||
"flash.post_saved_draft": "Статья сохранена как черновик!",
|
||||
"flash.post_updated": "Статья успешно обновлена!",
|
||||
"flash.post_deleted": "Статья успешно удалена!",
|
||||
"flash.post_not_found": "Статья не найдена.",
|
||||
"base.close_message": "Закрыть сообщение",
|
||||
"base.default_title": "Блог",
|
||||
"base.meta_description": "Блог — современная платформа для блогов на FastAPI",
|
||||
"base.meta_keywords": "блог, статьи, посты, письмо",
|
||||
"base.meta_author": "Команда блога",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Русский",
|
||||
"lang.fr": "Français",
|
||||
"lang.de": "Deutsch",
|
||||
},
|
||||
"fr": {
|
||||
"nav.home": "Accueil",
|
||||
"nav.posts": "Articles",
|
||||
"nav.about": "À propos",
|
||||
"header.logo": "Blog",
|
||||
"header.profile": "Profil",
|
||||
"header.new_post": "Nouvel article",
|
||||
"header.sign_out": "Déconnexion",
|
||||
"header.sign_in": "Connexion",
|
||||
"header.toggle_menu": "Menu",
|
||||
"header.toggle_theme": "Changer le thème",
|
||||
"header.lang_switcher": "Langue",
|
||||
"footer.copyright": "© 2026 Blog. Tous droits réservés.",
|
||||
"footer.about": "À propos",
|
||||
"footer.privacy": "Confidentialité",
|
||||
"footer.terms": "Conditions",
|
||||
"footer.api": "API",
|
||||
"home.title": "Blog — Accueil",
|
||||
"home.meta_description": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets. Un blog moderne avec FastAPI.",
|
||||
"home.meta_keywords": "blog, articles, posts, écriture, fastapi, python",
|
||||
"home.page_title": "Derniers articles",
|
||||
"home.page_subtitle": "Découvrez des histoires, réflexions et expertises d'auteurs sur tous les sujets.",
|
||||
"home.write_post": "Écrire un article",
|
||||
"home.status_published": "Publié",
|
||||
"home.status_draft": "Brouillon",
|
||||
"home.read_more": "Lire la suite",
|
||||
"home.pagination_previous": "Précédent",
|
||||
"home.pagination_next": "Suivant",
|
||||
"home.empty_title": "Aucun article pour le moment",
|
||||
"home.empty_description": "Soyez le premier à écrire un article !",
|
||||
"home.empty_action": "Créer votre premier article",
|
||||
"post.status_published": "Publié",
|
||||
"post.status_draft": "Brouillon",
|
||||
"post.back_to_posts": "Retour aux articles",
|
||||
"post.edit": "Modifier",
|
||||
"post.delete": "Supprimer",
|
||||
"post.delete_confirm": "Êtes-vous sûr de vouloir supprimer cet article ?",
|
||||
"post_form.title_edit": "Modifier l'article",
|
||||
"post_form.title_new": "Nouvel article",
|
||||
"post_form.page_title_edit": "Modifier l'article",
|
||||
"post_form.page_title_new": "Créer un nouvel article",
|
||||
"post_form.label_title": "Titre",
|
||||
"post_form.placeholder_title": "Entrez le titre de l'article",
|
||||
"post_form.hint_title": "Un titre accrocheur pour votre article",
|
||||
"post_form.label_content": "Contenu",
|
||||
"post_form.placeholder_content": "Écrivez votre article ici...",
|
||||
"post_form.hint_content": "Le contenu principal de votre article. Markdown est supporté.",
|
||||
"post_form.label_tags": "Tags",
|
||||
"post_form.placeholder_tags": "python, fastapi, tutorial",
|
||||
"post_form.hint_tags": "Liste de tags séparés par des virgules",
|
||||
"post_form.cancel": "Annuler",
|
||||
"post_form.save_draft": "Sauvegarder le brouillon",
|
||||
"post_form.update_post": "Mettre à jour",
|
||||
"post_form.publish_post": "Publier",
|
||||
"profile.title": "Profil utilisateur",
|
||||
"profile.email": "Email :",
|
||||
"profile.not_provided": "Non fourni",
|
||||
"profile.user_id": "ID utilisateur :",
|
||||
"profile.name": "Nom :",
|
||||
"profile.back_home": "Retour à l'accueil",
|
||||
"profile.new_post": "Nouvel article",
|
||||
"about.title": "À propos",
|
||||
"about.page_title": "À propos",
|
||||
"about.description": "Un blog moderne construit avec FastAPI et une architecture Domain-Driven Design.",
|
||||
"about.signed_in": "Connecté en tant que {username}.",
|
||||
"about.browsing_guest": "Vous naviguez en tant qu'invité.",
|
||||
"about.back_home": "Retour à l'accueil",
|
||||
"flash.post_published": "Article publié avec succès !",
|
||||
"flash.post_saved_draft": "Article sauvegardé comme brouillon !",
|
||||
"flash.post_updated": "Article mis à jour avec succès !",
|
||||
"flash.post_deleted": "Article supprimé avec succès !",
|
||||
"flash.post_not_found": "Article non trouvé.",
|
||||
"base.close_message": "Fermer le message",
|
||||
"base.default_title": "Blog",
|
||||
"base.meta_description": "Blog — Une plateforme de blog moderne construite avec FastAPI",
|
||||
"base.meta_keywords": "blog, articles, posts, écriture",
|
||||
"base.meta_author": "Équipe du blog",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Русский",
|
||||
"lang.fr": "Français",
|
||||
"lang.de": "Deutsch",
|
||||
},
|
||||
"de": {
|
||||
"nav.home": "Startseite",
|
||||
"nav.posts": "Beiträge",
|
||||
"nav.about": "Über uns",
|
||||
"header.logo": "Blog",
|
||||
"header.profile": "Profil",
|
||||
"header.new_post": "Neuer Beitrag",
|
||||
"header.sign_out": "Abmelden",
|
||||
"header.sign_in": "Anmelden",
|
||||
"header.toggle_menu": "Menü umschalten",
|
||||
"header.toggle_theme": "Design umschalten",
|
||||
"header.lang_switcher": "Sprache",
|
||||
"footer.copyright": "© 2026 Blog. Alle Rechte vorbehalten.",
|
||||
"footer.about": "Über uns",
|
||||
"footer.privacy": "Datenschutz",
|
||||
"footer.terms": "AGB",
|
||||
"footer.api": "API",
|
||||
"home.title": "Blog — Startseite",
|
||||
"home.meta_description": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema. Ein moderner Blog mit FastAPI.",
|
||||
"home.meta_keywords": "Blog, Artikel, Beiträge, Schreiben, Fastapi, Python",
|
||||
"home.page_title": "Neueste Beiträge",
|
||||
"home.page_subtitle": "Entdecken Sie Geschichten, Gedanken und Fachwissen von Autoren zu jedem Thema.",
|
||||
"home.write_post": "Beitrag schreiben",
|
||||
"home.status_published": "Veröffentlicht",
|
||||
"home.status_draft": "Entwurf",
|
||||
"home.read_more": "Weiterlesen",
|
||||
"home.pagination_previous": "Zurück",
|
||||
"home.pagination_next": "Weiter",
|
||||
"home.empty_title": "Noch keine Beiträge",
|
||||
"home.empty_description": "Schreiben Sie den ersten Beitrag!",
|
||||
"home.empty_action": "Ersten Beitrag erstellen",
|
||||
"post.status_published": "Veröffentlicht",
|
||||
"post.status_draft": "Entwurf",
|
||||
"post.back_to_posts": "Zurück zu den Beiträgen",
|
||||
"post.edit": "Bearbeiten",
|
||||
"post.delete": "Löschen",
|
||||
"post.delete_confirm": "Sind Sie sicher, dass Sie diesen Beitrag löschen möchten?",
|
||||
"post_form.title_edit": "Beitrag bearbeiten",
|
||||
"post_form.title_new": "Neuer Beitrag",
|
||||
"post_form.page_title_edit": "Beitrag bearbeiten",
|
||||
"post_form.page_title_new": "Neuen Beitrag erstellen",
|
||||
"post_form.label_title": "Titel",
|
||||
"post_form.placeholder_title": "Geben Sie den Beitragstitel ein",
|
||||
"post_form.hint_title": "Ein eingängiger Titel für Ihren Beitrag",
|
||||
"post_form.label_content": "Inhalt",
|
||||
"post_form.placeholder_content": "Schreiben Sie Ihren Beitrag hier...",
|
||||
"post_form.hint_content": "Der Hauptinhalt Ihres Beitrags. Markdown wird unterstützt.",
|
||||
"post_form.label_tags": "Tags",
|
||||
"post_form.placeholder_tags": "python, fastapi, tutorial",
|
||||
"post_form.hint_tags": "Kommagetrennte Tag-Liste",
|
||||
"post_form.cancel": "Abbrechen",
|
||||
"post_form.save_draft": "Als Entwurf speichern",
|
||||
"post_form.update_post": "Beitrag aktualisieren",
|
||||
"post_form.publish_post": "Beitrag veröffentlichen",
|
||||
"profile.title": "Benutzerprofil",
|
||||
"profile.email": "E-Mail:",
|
||||
"profile.not_provided": "Nicht angegeben",
|
||||
"profile.user_id": "Benutzer-ID:",
|
||||
"profile.name": "Name:",
|
||||
"profile.back_home": "Zurück zur Startseite",
|
||||
"profile.new_post": "Neuer Beitrag",
|
||||
"about.title": "Über uns",
|
||||
"about.page_title": "Über uns",
|
||||
"about.description": "Ein moderner Blog, erstellt mit FastAPI und Domain-Driven Design Architektur.",
|
||||
"about.signed_in": "Angemeldet als {username}.",
|
||||
"about.browsing_guest": "Sie surfen als Gast.",
|
||||
"about.back_home": "Zurück zur Startseite",
|
||||
"flash.post_published": "Beitrag erfolgreich veröffentlicht!",
|
||||
"flash.post_saved_draft": "Beitrag als Entwurf gespeichert!",
|
||||
"flash.post_updated": "Beitrag erfolgreich aktualisiert!",
|
||||
"flash.post_deleted": "Beitrag erfolgreich gelöscht!",
|
||||
"flash.post_not_found": "Beitrag nicht gefunden.",
|
||||
"base.close_message": "Nachricht schließen",
|
||||
"base.default_title": "Blog",
|
||||
"base.meta_description": "Blog — Eine moderne Blogging-Plattform mit FastAPI",
|
||||
"base.meta_keywords": "Blog, Artikel, Beiträge, Schreiben",
|
||||
"base.meta_author": "Blog-Team",
|
||||
"lang.en": "English",
|
||||
"lang.ru": "Русский",
|
||||
"lang.fr": "Français",
|
||||
"lang.de": "Deutsch",
|
||||
},
|
||||
}
|
||||
78
app/infrastructure/i18n/translator.py
Normal file
78
app/infrastructure/i18n/translator.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Translation service for i18n support.
|
||||
|
||||
This module provides the translation service that resolves translation keys
|
||||
to localized strings using in-memory translation dictionaries. Falls back
|
||||
from requested locale through English to the raw key.
|
||||
"""
|
||||
|
||||
from app.infrastructure.i18n.translations import TRANSLATIONS
|
||||
|
||||
SUPPORTED_LOCALES = frozenset({"en", "ru", "fr", "de"})
|
||||
DEFAULT_LOCALE = "en"
|
||||
|
||||
|
||||
class TranslationService:
|
||||
"""Service for resolving translation keys to localized strings.
|
||||
|
||||
Provides a singleton-like interface for translating UI strings
|
||||
across the application. Falls back through requested locale to
|
||||
English and finally to the raw key if no translation exists.
|
||||
|
||||
Attributes:
|
||||
translations: Dictionary of locale to key to string mappings.
|
||||
"""
|
||||
|
||||
_instance: "TranslationService | None" = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize translation service with translation data."""
|
||||
self.translations = TRANSLATIONS
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "TranslationService":
|
||||
"""Get or create the singleton instance.
|
||||
|
||||
Returns:
|
||||
The shared TranslationService instance.
|
||||
"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def get_text(self, key: str, locale: str = DEFAULT_LOCALE) -> str:
|
||||
"""Get translated text for a given key and locale.
|
||||
|
||||
Resolves the key through the locale chain: requested locale,
|
||||
then English fallback, then the raw key itself.
|
||||
|
||||
Args:
|
||||
key: Translation key (e.g. ``nav.home``).
|
||||
locale: Target locale code (e.g. ``en``, ``ru``, ``fr``, ``de``).
|
||||
|
||||
Returns:
|
||||
Translated string if found, otherwise the English version
|
||||
or the key itself as last resort.
|
||||
"""
|
||||
locale_translations = self.translations.get(locale)
|
||||
if locale_translations is not None and key in locale_translations:
|
||||
return locale_translations[key]
|
||||
|
||||
if locale != DEFAULT_LOCALE:
|
||||
fallback = self.translations.get(DEFAULT_LOCALE, {}).get(key)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def _(key: str, locale: str = DEFAULT_LOCALE) -> str:
|
||||
"""Convenience function for translating a single key.
|
||||
|
||||
Args:
|
||||
key: Translation key to look up.
|
||||
locale: Target locale code.
|
||||
|
||||
Returns:
|
||||
Translated string or the key itself if no translation found.
|
||||
"""
|
||||
return TranslationService.get_instance().get_text(key, locale)
|
||||
Reference in New Issue
Block a user