diff --git a/app/infrastructure/i18n/__init__.py b/app/infrastructure/i18n/__init__.py new file mode 100644 index 0000000..26716d3 --- /dev/null +++ b/app/infrastructure/i18n/__init__.py @@ -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", "_"] diff --git a/app/infrastructure/i18n/translations.py b/app/infrastructure/i18n/translations.py new file mode 100644 index 0000000..9e8a0df --- /dev/null +++ b/app/infrastructure/i18n/translations.py @@ -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", + }, +} diff --git a/app/infrastructure/i18n/translator.py b/app/infrastructure/i18n/translator.py new file mode 100644 index 0000000..b9d142f --- /dev/null +++ b/app/infrastructure/i18n/translator.py @@ -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) diff --git a/app/main.py b/app/main.py index fb50e65..84a9ad3 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,7 @@ from app.presentation.web import auth_router from app.presentation.web import router as web_router from app.presentation.web.error_handlers import register_error_handlers from app.presentation.web.flash import setup_flash_manager +from app.presentation.web.locale import setup_locale_manager @asynccontextmanager @@ -86,6 +87,15 @@ def app_factory() -> FastAPI: request.state.flash_manager.set_cookie(response) return response + @app.middleware("http") + async def locale_middleware( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + """Middleware to detect and set locale for each request.""" + await setup_locale_manager(request) + response = await call_next(request) + return response + app.add_middleware( CORSMiddleware, allow_origins=["*"], diff --git a/app/presentation/templates/base.html b/app/presentation/templates/base.html index 9ccf1ed..ad43323 100644 --- a/app/presentation/templates/base.html +++ b/app/presentation/templates/base.html @@ -1,11 +1,11 @@ - + - - - + + + @@ -30,7 +30,7 @@ - {% block title %}Blog{% endblock %} + {% block title %}{{ _('base.default_title', current_locale) }}{% endblock %} @@ -51,7 +51,7 @@ {% for msg in flash_messages %} {% endfor %} diff --git a/app/presentation/templates/pages/about.html b/app/presentation/templates/pages/about.html index 7ddd8f3..825cd6b 100644 --- a/app/presentation/templates/pages/about.html +++ b/app/presentation/templates/pages/about.html @@ -1,26 +1,26 @@ {% extends "base.html" %} -{% block title %}About - Blog{% endblock %} -{% block meta_description %}A modern blog built with FastAPI and DDD architecture.{% endblock %} +{% block title %}{{ _('about.title', current_locale) }} - {{ _('base.default_title', current_locale) }}{% endblock %} +{% block meta_description %}{{ _('about.description', current_locale) }}{% endblock %} {% block content %}

- A modern blog built with FastAPI and Domain-Driven Design architecture. + {{ _('about.description', current_locale) }}

{% if user %} - Signed in as {{ user.username }}. + {{ _('about.signed_in', current_locale).format(username=user.username) }} {% else %} - You are browsing as a guest. + {{ _('about.browsing_guest', current_locale) }} {% endif %}

@@ -30,7 +30,7 @@ - Back to Home + {{ _('about.back_home', current_locale) }}
diff --git a/app/presentation/templates/pages/index.html b/app/presentation/templates/pages/index.html index 5caf0f8..c63b86b 100644 --- a/app/presentation/templates/pages/index.html +++ b/app/presentation/templates/pages/index.html @@ -1,29 +1,29 @@ {% extends "base.html" %} -{% block title %}Blog - Home{% endblock %} -{% block meta_description %}Discover stories, thinking, and expertise from writers on any topic. A modern blog built with FastAPI.{% endblock %} -{% block meta_keywords %}blog, articles, posts, writing, fastapi, python{% endblock %} +{% block title %}{{ _('home.title', current_locale) }}{% endblock %} +{% block meta_description %}{{ _('home.meta_description', current_locale) }}{% endblock %} +{% block meta_keywords %}{{ _('home.meta_keywords', current_locale) }}{% endblock %} {% block og_type %}website{% endblock %} -{% block og_title %}Blog - Home{% endblock %} -{% block og_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %} +{% block og_title %}{{ _('home.title', current_locale) }}{% endblock %} +{% block og_description %}{{ _('home.meta_description', current_locale) }}{% endblock %} -{% block twitter_title %}Blog - Home{% endblock %} -{% block twitter_description %}Discover stories, thinking, and expertise from writers on any topic.{% endblock %} +{% block twitter_title %}{{ _('home.title', current_locale) }}{% endblock %} +{% block twitter_description %}{{ _('home.meta_description', current_locale) }}{% endblock %} {% block content %}