diff --git a/app/modules/base.py b/app/modules/base.py index 74a8a05a..93962a6a 100644 --- a/app/modules/base.py +++ b/app/modules/base.py @@ -61,6 +61,7 @@ class ModuleDefinition: schemas_path: Path to schemas subpackage (e.g., "app.modules.billing.schemas") templates_path: Path to templates directory (relative to module) exceptions_path: Path to exceptions module (e.g., "app.modules.billing.exceptions") + locales_path: Path to locales directory (relative to module, e.g., "locales") is_self_contained: Whether module uses self-contained structure Example (traditional thin wrapper): @@ -91,6 +92,7 @@ class ModuleDefinition: schemas_path="app.modules.cms.schemas", templates_path="templates", exceptions_path="app.modules.cms.exceptions", + locales_path="locales", ) """ @@ -120,6 +122,7 @@ class ModuleDefinition: schemas_path: str | None = None templates_path: str | None = None # Relative to module directory exceptions_path: str | None = None + locales_path: str | None = None # Relative to module directory, e.g., "locales" def get_menu_items(self, frontend_type: FrontendType) -> list[str]: """Get menu item IDs for a specific frontend type.""" @@ -176,6 +179,17 @@ class ModuleDefinition: return None return self.get_module_dir() / self.templates_path + def get_locales_dir(self) -> Path | None: + """ + Get the filesystem path to this module's locales directory. + + Returns: + Path to locales directory, or None if not configured + """ + if not self.is_self_contained or not self.locales_path: + return None + return self.get_module_dir() / self.locales_path + def get_import_path(self, component: str) -> str | None: """ Get the Python import path for a module component. @@ -221,6 +235,8 @@ class ModuleDefinition: expected_dirs.append("schemas") if self.templates_path: expected_dirs.append(self.templates_path) + if self.locales_path: + expected_dirs.append(self.locales_path) for dir_name in expected_dirs: dir_path = module_dir / dir_name diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index aa60635b..49db8591 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -63,6 +63,8 @@ cms_module = ModuleDefinition( exceptions_path="app.modules.cms.exceptions", # Templates remain in core for now (admin/content-pages*.html) templates_path=None, + # Module-specific translations (accessible via cms.* keys) + locales_path="locales", ) diff --git a/app/modules/cms/locales/de.json b/app/modules/cms/locales/de.json new file mode 100644 index 00000000..1a5c2299 --- /dev/null +++ b/app/modules/cms/locales/de.json @@ -0,0 +1,126 @@ +{ + "title": "Content-Verwaltung", + "description": "Verwalten Sie Inhaltsseiten, Medienbibliothek und Händler-Themes", + "pages": { + "title": "Inhaltsseiten", + "subtitle": "Verwalten Sie Plattform- und Händler-Inhaltsseiten", + "create": "Seite erstellen", + "edit": "Seite bearbeiten", + "delete": "Seite löschen", + "list": "Alle Seiten", + "empty": "Keine Seiten gefunden", + "empty_search": "Keine Seiten entsprechen Ihrer Suche", + "create_first": "Erste Seite erstellen" + }, + "page": { + "title": "Seitentitel", + "slug": "Slug", + "slug_help": "URL-sichere Kennung (Kleinbuchstaben, Zahlen, Bindestriche)", + "content": "Inhalt", + "content_format": "Inhaltsformat", + "format_html": "HTML", + "format_markdown": "Markdown", + "platform": "Plattform", + "vendor_override": "Händler-Überschreibung", + "vendor_override_none": "Keine (Plattform-Standard)", + "vendor_override_help_default": "Dies ist eine plattformweite Standardseite", + "vendor_override_help_vendor": "Diese Seite überschreibt den Standard nur für den ausgewählten Händler" + }, + "tiers": { + "platform": "Plattform-Marketing", + "vendor_default": "Händler-Standard", + "vendor_override": "Händler-Überschreibung" + }, + "seo": { + "title": "SEO & Metadaten", + "meta_description": "Meta-Beschreibung", + "meta_description_help": "Zeichen (150-160 empfohlen)", + "meta_keywords": "Meta-Schlüsselwörter", + "meta_keywords_placeholder": "schlüsselwort1, schlüsselwort2, schlüsselwort3" + }, + "navigation": { + "title": "Navigation & Anzeige", + "display_order": "Anzeigereihenfolge", + "display_order_help": "Niedriger = zuerst", + "show_in_header": "Im Header anzeigen", + "show_in_footer": "Im Footer anzeigen", + "show_in_legal": "Im Rechtsbereich anzeigen", + "show_in_legal_help": "Untere Leiste neben dem Copyright" + }, + "publishing": { + "published": "Veröffentlicht", + "draft": "Entwurf", + "publish_help": "Diese Seite öffentlich sichtbar machen" + }, + "homepage": { + "title": "Startseiten-Abschnitte", + "subtitle": "Mehrsprachiger Inhalt", + "loading": "Abschnitte werden geladen...", + "hero": { + "title": "Hero-Abschnitt", + "badge_text": "Badge-Text", + "main_title": "Titel", + "subtitle": "Untertitel", + "buttons": "Schaltflächen", + "add_button": "Schaltfläche hinzufügen" + }, + "features": { + "title": "Funktionen-Abschnitt", + "section_title": "Abschnittstitel", + "cards": "Funktionskarten", + "add_card": "Karte hinzufügen", + "icon": "Icon-Name", + "feature_title": "Titel", + "feature_description": "Beschreibung" + }, + "pricing": { + "title": "Preise-Abschnitt", + "section_title": "Abschnittstitel", + "use_tiers": "Abonnement-Stufen aus der Datenbank verwenden", + "use_tiers_help": "Wenn aktiviert, werden Preiskarten dynamisch aus Ihrer Abonnement-Stufenkonfiguration abgerufen." + }, + "cta": { + "title": "Call-to-Action-Abschnitt", + "main_title": "Titel", + "subtitle": "Untertitel", + "buttons": "Schaltflächen", + "add_button": "Schaltfläche hinzufügen" + } + }, + "media": { + "title": "Medienbibliothek", + "upload": "Hochladen", + "upload_file": "Datei hochladen", + "delete": "Löschen", + "empty": "Keine Mediendateien", + "upload_first": "Laden Sie Ihre erste Datei hoch" + }, + "themes": { + "title": "Händler-Themes", + "subtitle": "Verwalten Sie Händler-Theme-Anpassungen" + }, + "actions": { + "save": "Speichern", + "saving": "Speichern...", + "update": "Seite aktualisieren", + "create": "Seite erstellen", + "cancel": "Abbrechen", + "back_to_list": "Zurück zur Liste", + "preview": "Vorschau", + "revert_to_default": "Auf Standard zurücksetzen" + }, + "messages": { + "created": "Seite erfolgreich erstellt", + "updated": "Seite erfolgreich aktualisiert", + "deleted": "Seite erfolgreich gelöscht", + "reverted": "Auf Standardseite zurückgesetzt", + "error_loading": "Fehler beim Laden der Seite", + "error_saving": "Fehler beim Speichern der Seite", + "confirm_delete": "Sind Sie sicher, dass Sie diese Seite löschen möchten?" + }, + "filters": { + "all_pages": "Alle Seiten", + "all_platforms": "Alle Plattformen", + "search_placeholder": "Seiten suchen..." + } +} diff --git a/app/modules/cms/locales/en.json b/app/modules/cms/locales/en.json new file mode 100644 index 00000000..fe0bc0d4 --- /dev/null +++ b/app/modules/cms/locales/en.json @@ -0,0 +1,126 @@ +{ + "title": "Content Management", + "description": "Manage content pages, media library, and vendor themes", + "pages": { + "title": "Content Pages", + "subtitle": "Manage platform and vendor content pages", + "create": "Create Page", + "edit": "Edit Page", + "delete": "Delete Page", + "list": "All Pages", + "empty": "No pages found", + "empty_search": "No pages match your search", + "create_first": "Create First Page" + }, + "page": { + "title": "Page Title", + "slug": "Slug", + "slug_help": "URL-safe identifier (lowercase, numbers, hyphens only)", + "content": "Content", + "content_format": "Content Format", + "format_html": "HTML", + "format_markdown": "Markdown", + "platform": "Platform", + "vendor_override": "Vendor Override", + "vendor_override_none": "None (Platform Default)", + "vendor_override_help_default": "This is a platform-wide default page", + "vendor_override_help_vendor": "This page overrides the default for selected vendor only" + }, + "tiers": { + "platform": "Platform Marketing", + "vendor_default": "Vendor Default", + "vendor_override": "Vendor Override" + }, + "seo": { + "title": "SEO & Metadata", + "meta_description": "Meta Description", + "meta_description_help": "characters (150-160 recommended)", + "meta_keywords": "Meta Keywords", + "meta_keywords_placeholder": "keyword1, keyword2, keyword3" + }, + "navigation": { + "title": "Navigation & Display", + "display_order": "Display Order", + "display_order_help": "Lower = first", + "show_in_header": "Show in Header", + "show_in_footer": "Show in Footer", + "show_in_legal": "Show in Legal", + "show_in_legal_help": "Bottom bar next to copyright" + }, + "publishing": { + "published": "Published", + "draft": "Draft", + "publish_help": "Make this page visible to the public" + }, + "homepage": { + "title": "Homepage Sections", + "subtitle": "Multi-language content", + "loading": "Loading sections...", + "hero": { + "title": "Hero Section", + "badge_text": "Badge Text", + "main_title": "Title", + "subtitle": "Subtitle", + "buttons": "Buttons", + "add_button": "Add Button" + }, + "features": { + "title": "Features Section", + "section_title": "Section Title", + "cards": "Feature Cards", + "add_card": "Add Feature Card", + "icon": "Icon name", + "feature_title": "Title", + "feature_description": "Description" + }, + "pricing": { + "title": "Pricing Section", + "section_title": "Section Title", + "use_tiers": "Use subscription tiers from database", + "use_tiers_help": "When enabled, pricing cards are dynamically pulled from your subscription tier configuration." + }, + "cta": { + "title": "Call to Action Section", + "main_title": "Title", + "subtitle": "Subtitle", + "buttons": "Buttons", + "add_button": "Add Button" + } + }, + "media": { + "title": "Media Library", + "upload": "Upload", + "upload_file": "Upload File", + "delete": "Delete", + "empty": "No media files", + "upload_first": "Upload your first file" + }, + "themes": { + "title": "Vendor Themes", + "subtitle": "Manage vendor theme customizations" + }, + "actions": { + "save": "Save", + "saving": "Saving...", + "update": "Update Page", + "create": "Create Page", + "cancel": "Cancel", + "back_to_list": "Back to List", + "preview": "Preview", + "revert_to_default": "Revert to Default" + }, + "messages": { + "created": "Page created successfully", + "updated": "Page updated successfully", + "deleted": "Page deleted successfully", + "reverted": "Reverted to default page", + "error_loading": "Error loading page", + "error_saving": "Error saving page", + "confirm_delete": "Are you sure you want to delete this page?" + }, + "filters": { + "all_pages": "All Pages", + "all_platforms": "All Platforms", + "search_placeholder": "Search pages..." + } +} diff --git a/app/modules/cms/locales/fr.json b/app/modules/cms/locales/fr.json new file mode 100644 index 00000000..776a79a0 --- /dev/null +++ b/app/modules/cms/locales/fr.json @@ -0,0 +1,126 @@ +{ + "title": "Gestion de contenu", + "description": "Gestion des pages de contenu, de la bibliothèque de médias et des thèmes", + "pages": { + "title": "Pages de contenu", + "subtitle": "Gérez les pages de contenu de la plateforme et des vendeurs", + "create": "Créer une page", + "edit": "Modifier la page", + "delete": "Supprimer la page", + "list": "Toutes les pages", + "empty": "Aucune page trouvée", + "empty_search": "Aucune page ne correspond à votre recherche", + "create_first": "Créer la première page" + }, + "page": { + "title": "Titre de la page", + "slug": "Slug", + "slug_help": "Identifiant URL (minuscules, chiffres, tirets uniquement)", + "content": "Contenu", + "content_format": "Format du contenu", + "format_html": "HTML", + "format_markdown": "Markdown", + "platform": "Plateforme", + "vendor_override": "Remplacement vendeur", + "vendor_override_none": "Aucun (page par défaut)", + "vendor_override_help_default": "Ceci est une page par défaut pour toute la plateforme", + "vendor_override_help_vendor": "Cette page remplace la page par défaut pour le vendeur sélectionné" + }, + "tiers": { + "platform": "Marketing plateforme", + "vendor_default": "Défaut vendeur", + "vendor_override": "Remplacement vendeur" + }, + "seo": { + "title": "SEO & Métadonnées", + "meta_description": "Meta Description", + "meta_description_help": "caractères (150-160 recommandés)", + "meta_keywords": "Mots-clés", + "meta_keywords_placeholder": "mot-clé1, mot-clé2, mot-clé3" + }, + "navigation": { + "title": "Navigation & Affichage", + "display_order": "Ordre d'affichage", + "display_order_help": "Plus bas = premier", + "show_in_header": "Afficher dans l'en-tête", + "show_in_footer": "Afficher dans le pied de page", + "show_in_legal": "Afficher dans les mentions légales", + "show_in_legal_help": "Barre en bas à côté du copyright" + }, + "publishing": { + "published": "Publié", + "draft": "Brouillon", + "publish_help": "Rendre cette page visible au public" + }, + "homepage": { + "title": "Sections de la page d'accueil", + "subtitle": "Contenu multilingue", + "loading": "Chargement des sections...", + "hero": { + "title": "Section Hero", + "badge_text": "Texte du badge", + "main_title": "Titre", + "subtitle": "Sous-titre", + "buttons": "Boutons", + "add_button": "Ajouter un bouton" + }, + "features": { + "title": "Section Fonctionnalités", + "section_title": "Titre de la section", + "cards": "Cartes de fonctionnalités", + "add_card": "Ajouter une carte", + "icon": "Nom de l'icône", + "feature_title": "Titre", + "feature_description": "Description" + }, + "pricing": { + "title": "Section Tarifs", + "section_title": "Titre de la section", + "use_tiers": "Utiliser les niveaux d'abonnement de la base de données", + "use_tiers_help": "Si activé, les cartes de tarifs sont extraites dynamiquement de la configuration des niveaux d'abonnement." + }, + "cta": { + "title": "Section Appel à l'action", + "main_title": "Titre", + "subtitle": "Sous-titre", + "buttons": "Boutons", + "add_button": "Ajouter un bouton" + } + }, + "media": { + "title": "Bibliothèque de médias", + "upload": "Télécharger", + "upload_file": "Télécharger un fichier", + "delete": "Supprimer", + "empty": "Aucun fichier média", + "upload_first": "Téléchargez votre premier fichier" + }, + "themes": { + "title": "Thèmes vendeurs", + "subtitle": "Gérez les personnalisations de thèmes des vendeurs" + }, + "actions": { + "save": "Enregistrer", + "saving": "Enregistrement...", + "update": "Mettre à jour la page", + "create": "Créer la page", + "cancel": "Annuler", + "back_to_list": "Retour à la liste", + "preview": "Aperçu", + "revert_to_default": "Revenir à la valeur par défaut" + }, + "messages": { + "created": "Page créée avec succès", + "updated": "Page mise à jour avec succès", + "deleted": "Page supprimée avec succès", + "reverted": "Retour à la page par défaut", + "error_loading": "Erreur lors du chargement de la page", + "error_saving": "Erreur lors de l'enregistrement de la page", + "confirm_delete": "Êtes-vous sûr de vouloir supprimer cette page ?" + }, + "filters": { + "all_pages": "Toutes les pages", + "all_platforms": "Toutes les plateformes", + "search_placeholder": "Rechercher des pages..." + } +} diff --git a/app/modules/cms/locales/lb.json b/app/modules/cms/locales/lb.json new file mode 100644 index 00000000..f74b9668 --- /dev/null +++ b/app/modules/cms/locales/lb.json @@ -0,0 +1,126 @@ +{ + "title": "Inhalts-Verwaltung", + "description": "Verwaltet Inhaltsäiten, Mediebibliothéik an Händler-Themen", + "pages": { + "title": "Inhaltsäiten", + "subtitle": "Verwaltet Plattform- an Händler-Inhaltsäiten", + "create": "Säit erstellen", + "edit": "Säit änneren", + "delete": "Säit läschen", + "list": "All Säiten", + "empty": "Keng Säite fonnt", + "empty_search": "Keng Säite passen op Är Sich", + "create_first": "Éischt Säit erstellen" + }, + "page": { + "title": "Säitentitel", + "slug": "Slug", + "slug_help": "URL-sécher Kennung (Klengbuschtawen, Zuelen, Bindestricher)", + "content": "Inhalt", + "content_format": "Inhaltsformat", + "format_html": "HTML", + "format_markdown": "Markdown", + "platform": "Plattform", + "vendor_override": "Händler-Iwwerschreiwung", + "vendor_override_none": "Keng (Plattform-Standard)", + "vendor_override_help_default": "Dëst ass eng plattformwäit Standardsäit", + "vendor_override_help_vendor": "Dës Säit iwwerschreift de Standard nëmme fir de gewielte Händler" + }, + "tiers": { + "platform": "Plattform-Marketing", + "vendor_default": "Händler-Standard", + "vendor_override": "Händler-Iwwerschreiwung" + }, + "seo": { + "title": "SEO & Metadaten", + "meta_description": "Meta-Beschreiwung", + "meta_description_help": "Zeechen (150-160 recommandéiert)", + "meta_keywords": "Meta-Schlësselwierder", + "meta_keywords_placeholder": "schlësselwuert1, schlësselwuert2, schlësselwuert3" + }, + "navigation": { + "title": "Navigatioun & Affichage", + "display_order": "Uweisungsreiefolleg", + "display_order_help": "Méi niddreg = éischt", + "show_in_header": "Am Header weisen", + "show_in_footer": "Am Footer weisen", + "show_in_legal": "Am Rechtsberäich weisen", + "show_in_legal_help": "Ënnescht Leist nieft dem Copyright" + }, + "publishing": { + "published": "Verëffentlecht", + "draft": "Entworf", + "publish_help": "Dës Säit ëffentlech siichtbar maachen" + }, + "homepage": { + "title": "Haaptsäit-Sektiounen", + "subtitle": "Méisproochegen Inhalt", + "loading": "Sektiounen ginn gelueden...", + "hero": { + "title": "Hero-Sektioun", + "badge_text": "Badge-Text", + "main_title": "Titel", + "subtitle": "Ënnertitel", + "buttons": "Knäpp", + "add_button": "Knapp derbäisetzen" + }, + "features": { + "title": "Funktiounen-Sektioun", + "section_title": "Sektiounstitel", + "cards": "Funktiounskaarten", + "add_card": "Kaart derbäisetzen", + "icon": "Icon-Numm", + "feature_title": "Titel", + "feature_description": "Beschreiwung" + }, + "pricing": { + "title": "Präisser-Sektioun", + "section_title": "Sektiounstitel", + "use_tiers": "Abonnement-Stufen aus der Datebank benotzen", + "use_tiers_help": "Wann aktivéiert, ginn d'Präiskaarten dynamesch aus Ärer Abonnement-Stufekonfiguratioun ofgeruff." + }, + "cta": { + "title": "Call-to-Action-Sektioun", + "main_title": "Titel", + "subtitle": "Ënnertitel", + "buttons": "Knäpp", + "add_button": "Knapp derbäisetzen" + } + }, + "media": { + "title": "Mediebibliothéik", + "upload": "Eroplueden", + "upload_file": "Fichier eroplueden", + "delete": "Läschen", + "empty": "Keng Mediefichieren", + "upload_first": "Luet Äre éischte Fichier erop" + }, + "themes": { + "title": "Händler-Themen", + "subtitle": "Verwaltet Händler-Theme-Personnalisatiounen" + }, + "actions": { + "save": "Späicheren", + "saving": "Späicheren...", + "update": "Säit aktualiséieren", + "create": "Säit erstellen", + "cancel": "Ofbriechen", + "back_to_list": "Zréck op d'Lëscht", + "preview": "Virschau", + "revert_to_default": "Op Standard zrécksetzen" + }, + "messages": { + "created": "Säit erfollegräich erstallt", + "updated": "Säit erfollegräich aktualiséiert", + "deleted": "Säit erfollegräich geläscht", + "reverted": "Op Standardsäit zréckgesat", + "error_loading": "Feeler beim Lueden vun der Säit", + "error_saving": "Feeler beim Späichere vun der Säit", + "confirm_delete": "Sidd Dir sécher, datt Dir dës Säit läsche wëllt?" + }, + "filters": { + "all_pages": "All Säiten", + "all_platforms": "All Plattformen", + "search_placeholder": "Säite sichen..." + } +} diff --git a/app/utils/i18n.py b/app/utils/i18n.py index 555f1316..31ccdc7e 100644 --- a/app/utils/i18n.py +++ b/app/utils/i18n.py @@ -61,57 +61,145 @@ LANGUAGE_FLAGS = { # Translation Storage # ============================================================================ -# Path to locale files +# Path to core locale files LOCALES_PATH = Path(__file__).parent.parent.parent / "static" / "locales" +# Path to modules directory (for module-specific locales) +MODULES_PATH = Path(__file__).parent.parent / "modules" + # In-memory cache for loaded translations _translations: dict[str, dict] = {} +# Cache for module locale directories +_module_locale_dirs: list[tuple[str, Path]] | None = None + def get_locales_path() -> Path: - """Get the path to locale files.""" + """Get the path to core locale files.""" return LOCALES_PATH +def get_module_locale_dirs() -> list[tuple[str, Path]]: + """ + Discover module locale directories. + + Returns: + List of (module_code, locales_path) tuples for modules with locales + """ + global _module_locale_dirs + + if _module_locale_dirs is not None: + return _module_locale_dirs + + _module_locale_dirs = [] + + if not MODULES_PATH.exists(): + return _module_locale_dirs + + for module_dir in sorted(MODULES_PATH.iterdir()): + if module_dir.is_dir(): + locales_dir = module_dir / "locales" + if locales_dir.exists() and locales_dir.is_dir(): + module_code = module_dir.name + _module_locale_dirs.append((module_code, locales_dir)) + logger.debug(f"[i18n] Found module locales: {module_code} at {locales_dir}") + + return _module_locale_dirs + + +def _load_json_file(file_path: Path) -> dict: + """Load a JSON file and return its contents.""" + try: + with open(file_path, encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in translation file {file_path}: {e}") + return {} + except Exception as e: + logger.error(f"Error loading translation file {file_path}: {e}") + return {} + + +def _deep_merge(base: dict, override: dict) -> dict: + """ + Deep merge two dictionaries. Override values take precedence. + + Args: + base: Base dictionary + override: Dictionary with values to merge/override + + Returns: + Merged dictionary + """ + result = base.copy() + + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + + return result + + @lru_cache(maxsize=10) def load_translations(language: str) -> dict: """ - Load translations for a specific language from JSON file. + Load translations for a specific language from JSON files. + + Loads core translations first, then merges module-specific translations. + Module translations are namespaced under their module code. Args: language: Language code (en, fr, de, lb) Returns: Dictionary of translations, empty dict if file not found + + Example: + Core: {"common": {"save": "Save"}} + CMS module: {"pages": {"title": "Page Title"}} + Result: {"common": {"save": "Save"}, "cms": {"pages": {"title": "Page Title"}}} """ if language not in SUPPORTED_LANGUAGES: logger.warning(f"Unsupported language requested: {language}") language = DEFAULT_LANGUAGE - locale_file = LOCALES_PATH / f"{language}.json" - - if not locale_file.exists(): - logger.warning(f"Translation file not found: {locale_file}") - # Fall back to default language + # Load core translations + core_file = LOCALES_PATH / f"{language}.json" + if core_file.exists(): + translations = _load_json_file(core_file) + else: + logger.warning(f"Core translation file not found: {core_file}") if language != DEFAULT_LANGUAGE: return load_translations(DEFAULT_LANGUAGE) - return {} + translations = {} - try: - with open(locale_file, encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError as e: - logger.error(f"Invalid JSON in translation file {locale_file}: {e}") - return {} - except Exception as e: - logger.error(f"Error loading translation file {locale_file}: {e}") - return {} + # Load and merge module translations + for module_code, locales_dir in get_module_locale_dirs(): + module_file = locales_dir / f"{language}.json" + if module_file.exists(): + module_translations = _load_json_file(module_file) + if module_translations: + # Namespace module translations under module code + if module_code in translations: + # Merge with existing (e.g., if core has some cms.* keys) + translations[module_code] = _deep_merge( + translations[module_code], module_translations + ) + else: + translations[module_code] = module_translations + logger.debug(f"[i18n] Loaded {language} translations for module: {module_code}") + + return translations def clear_translation_cache(): """Clear the translation cache (useful for development/testing).""" + global _module_locale_dirs load_translations.cache_clear() _translations.clear() + _module_locale_dirs = None # ============================================================================