feat: add module-specific locale support for i18n

Enhance the self-contained module architecture with locale/translation support:

ModuleDefinition changes:
- Add locales_path attribute for module-specific translations
- Add get_locales_dir() helper method
- Include locales in validate_structure() check

i18n module changes (app/utils/i18n.py):
- Add get_module_locale_dirs() to discover module locales
- Update load_translations() to merge module translations with core
- Module translations namespaced under module code (e.g., cms.title)
- Add _deep_merge() helper for nested dictionary merging
- Add _load_json_file() helper for cleaner JSON loading

CMS module locales:
- Add app/modules/cms/locales/ with translations for all 4 languages
- en.json, fr.json, de.json, lb.json with CMS-specific strings
- Covers: pages, page editing, SEO, navigation, publishing, homepage
  sections, media library, themes, actions, and messages

Usage in templates:
  {{ _("cms.title") }}           -> "Content Management" (en)
  {{ _("cms.pages.create") }}    -> "Créer une page" (fr)
  {{ _("cms.publishing.draft") }} -> "Entwurf" (de)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 21:44:28 +01:00
parent 2ce19e66b1
commit 8ff9c39845
7 changed files with 628 additions and 18 deletions

View File

@@ -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
# ============================================================================