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:
@@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user