Files
orion/docs/architecture/language-i18n.md
Samir Boulahtit d648c921b7
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
docs: add consolidated dev URL reference and migrate /shop to /storefront
- Add Development URL Quick Reference section to url-routing overview
  with all login URLs, entry points, and full examples
- Replace /shop/ path segments with /storefront/ across 50 docs files
- Update file references: shop_pages.py → storefront_pages.py,
  templates/shop/ → templates/storefront/, api/v1/shop/ → api/v1/storefront/
- Preserve domain references (orion.shop) and /store/ staff dashboard paths
- Archive docs left unchanged (historical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:23:44 +01:00

16 KiB

Language & Internationalization (i18n) Architecture

This document defines strict rules for implementing language support across the Orion platform.

IMPORTANT: These rules are mandatory. Violations will cause runtime errors, inconsistent UX, or security issues.


Table of Contents

  1. Supported Languages
  2. Language Context Flow
  3. Database Schema Rules
  4. Frontend Rules
  5. API Rules
  6. Template Rules
  7. JavaScript Rules
  8. Translation File Rules

Supported Languages

Code Language Flag Code Notes
en English gb Fallback language
fr French fr Default for Luxembourg
de German de Second official language
lb Luxembourgish lu Native language

Rule LANG-001: Only Use Supported Language Codes

# ✅ GOOD: Use supported codes
SUPPORTED_LANGUAGES = ["en", "fr", "de", "lb"]

# ❌ BAD: Invalid codes
language = "english"  # ❌ Use "en"
language = "french"   # ❌ Use "fr"
language = "lux"      # ❌ Use "lb"

Language Context Flow

Resolution Priority (per Frontend Type)

Language resolution varies by frontend type. Each chain is evaluated top-to-bottom, first match wins:

Frontend Priority (highest → lowest)
ADMIN Cookie (lang) → User preferred_language"en"
MERCHANT Cookie (lang) → User preferred_language"fr"
STORE Cookie (lang) → User preferred_language → Store dashboard_language"fr"
STOREFRONT Customer preferred_language → Cookie (lang) → Store storefront_language → Browser Accept-Language"fr"
PLATFORM Cookie (lang) → Browser Accept-Language"fr"

The cookie (lang) is set by the language switcher UI via POST /api/v1/platform/language/set and represents the user's most recent explicit language choice. It takes priority over database preferences because it reflects an immediate UI action.

Database Fields

Context Language Source Database Field
Admin Panel User's preferred_language users.preferred_language
Merchant Portal User's preferred_language users.preferred_language
Store Dashboard Store's dashboard_language stores.dashboard_language
Customer Storefront Store's storefront_language stores.storefront_language

Database Schema Rules

Rule DB-001: Store Language Fields Are Required

Stores MUST have these language columns with defaults:

# ✅ GOOD: All language fields with defaults
class Store(Base):
    default_language = Column(String(5), nullable=False, default="fr")
    dashboard_language = Column(String(5), nullable=False, default="fr")
    storefront_language = Column(String(5), nullable=False, default="fr")
    storefront_languages = Column(JSON, nullable=False, default=["fr", "de", "en"])
# ❌ BAD: Nullable language fields
class Store(Base):
    default_language = Column(String(5), nullable=True)  # ❌ Must have default

Rule DB-002: User/Customer Preferred Language Is Optional

# ✅ GOOD: Optional with fallback logic
class User(Base):
    preferred_language = Column(String(5), nullable=True)  # Falls back to store/platform default

class Customer(Base):
    preferred_language = Column(String(5), nullable=True)  # Falls back to storefront_language

Rule DB-003: Pydantic Schemas Must Handle Missing Language Fields

# ✅ GOOD: Optional in response with defaults
class StoreResponse(BaseModel):
    default_language: str = "fr"
    dashboard_language: str = "fr"
    storefront_language: str = "fr"
    storefront_languages: list[str] = ["fr", "de", "en"]

# ❌ BAD: Required without defaults (breaks backward compatibility)
class StoreResponse(BaseModel):
    default_language: str  # ❌ Will fail if DB doesn't have value

Frontend Rules

Rule FE-001: NEVER Use Inline Complex Alpine.js Data for Language Selector

Move complex JavaScript objects to functions. Inline x-data with Jinja breaks JSON serialization.

<!-- ❌ BAD: Inline complex object with Jinja variable -->
<div x-data="{
    isLangOpen: false,
    currentLang: '{{ request.state.language }}',
    languages: {{ enabled_langs }},  <!-- ❌ Jinja outputs Python list, not JSON -->
    async setLanguage(lang) {
        // Complex function here
    }
}">
<!-- ✅ GOOD: Use function with tojson|safe filter -->
<div x-data="languageSelector('{{ request.state.language|default("fr") }}', {{ enabled_langs|tojson|safe }})">
// ✅ GOOD: Define function in JavaScript file
function languageSelector(currentLang, enabledLanguages) {
    return {
        isLangOpen: false,
        currentLang: currentLang || 'fr',
        languages: enabledLanguages || ['fr', 'de', 'en'],
        languageNames: {
            'en': 'English',
            'fr': 'Français',
            'de': 'Deutsch',
            'lb': 'Lëtzebuergesch'
        },
        languageFlags: {
            'en': 'gb',
            'fr': 'fr',
            'de': 'de',
            'lb': 'lu'
        },
        async setLanguage(lang) {
            // Implementation
        }
    };
}

Rule FE-002: Always Use tojson|safe for Python Lists in JavaScript

<!-- ❌ BAD: Raw Jinja output -->
<div x-data="{ languages: {{ store.storefront_languages }} }">
<!-- Outputs: ['fr', 'de'] - Python syntax, invalid JavaScript -->

<!-- ❌ BAD: tojson without safe -->
<div x-data="{ languages: {{ store.storefront_languages|tojson }} }">
<!-- May escape quotes to &quot; in HTML context -->

<!-- ✅ GOOD: tojson with safe -->
<div x-data="{ languages: {{ store.storefront_languages|tojson|safe }} }">
<!-- Outputs: ["fr", "de"] - Valid JSON/JavaScript -->

Rule FE-003: Language Selector Must Be In Shared JavaScript

Language selector function MUST be defined in:

  • static/storefront/js/storefront-layout.js for storefront
  • static/store/js/init-alpine.js for store dashboard
// ✅ GOOD: Reusable function with consistent implementation
function languageSelector(currentLang, enabledLanguages) {
    return {
        isLangOpen: false,
        currentLang: currentLang || 'fr',
        languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
        languageNames: {
            'en': 'English',
            'fr': 'Français',
            'de': 'Deutsch',
            'lb': 'Lëtzebuergesch'
        },
        languageFlags: {
            'en': 'gb',
            'fr': 'fr',
            'de': 'de',
            'lb': 'lu'
        },
        async setLanguage(lang) {
            if (lang === this.currentLang) {
                this.isLangOpen = false;
                return;
            }
            try {
                const response = await fetch('/api/v1/platform/language/set', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ language: lang })
                });
                if (response.ok) {
                    this.currentLang = lang;
                    window.location.reload();
                }
            } catch (error) {
                console.error('Failed to set language:', error);
            }
            this.isLangOpen = false;
        }
    };
}

window.languageSelector = languageSelector;

Rule FE-004: Storefront Must Respect Store's Enabled Languages

<!-- ✅ GOOD: Only show languages enabled by store -->
{% set enabled_langs = store.storefront_languages if store and store.storefront_languages else ['fr', 'de', 'en'] %}
{% if enabled_langs|length > 1 %}
<div x-data="languageSelector('{{ request.state.language|default("fr") }}', {{ enabled_langs|tojson|safe }})">
    <!-- Language selector UI -->
</div>
{% endif %}
<!-- ❌ BAD: Hardcoded language list ignoring store settings -->
<div x-data="languageSelector('fr', ['en', 'fr', 'de', 'lb'])">
    <!-- Shows all languages regardless of store config -->
</div>

Rule FE-005: Store Dashboard Shows All Languages

<!-- ✅ GOOD: Store dashboard always shows all 4 languages -->
<div x-data="languageSelector('{{ request.state.language|default("fr") }}', ['en', 'fr', 'de', 'lb'])">

API Rules

# ✅ GOOD: Set both cookie and return JSON
@router.post("/language/set")
async def set_language(request: LanguageSetRequest, response: Response):
    if request.language not in SUPPORTED_LANGUAGES:
        raise HTTPException(status_code=400, detail="Unsupported language")

    response.set_cookie(
        key="lang",
        value=request.language,
        max_age=365 * 24 * 60 * 60,  # 1 year
        httponly=False,  # Accessible to JavaScript
        samesite="lax",
        path="/",  # Must be site-wide
    )
    return {"success": True, "language": request.language}

Rule API-002: Validate Language Codes

# ✅ GOOD: Strict validation
SUPPORTED_LANGUAGES = {"en", "fr", "de", "lb"}

class LanguageSetRequest(BaseModel):
    language: str = Field(..., pattern="^(en|fr|de|lb)$")

# ❌ BAD: No validation
class LanguageSetRequest(BaseModel):
    language: str  # ❌ Accepts any string

Template Rules

Rule TPL-001: Always Provide Language Default

<!-- ✅ GOOD: Default value -->
{{ request.state.language|default("fr") }}

<!-- ❌ BAD: No default -->
{{ request.state.language }}  <!-- ❌ May be None -->

Rule TPL-002: Check Language Array Length Before Rendering Selector

<!-- ✅ GOOD: Only show if multiple languages -->
{% if enabled_langs|length > 1 %}
    <!-- Language selector -->
{% endif %}

<!-- ❌ BAD: Always show even with single language -->
<!-- Language selector shown with only 1 option -->

Rule TPL-003: Use Consistent Flag Icon Classes

<!-- ✅ GOOD: Use flag-icons library consistently -->
<span class="fi fi-{{ flag_code }}"></span>

<!-- Flag codes mapping -->
<!-- en → gb (Great Britain) -->
<!-- fr → fr (France) -->
<!-- de → de (Germany) -->
<!-- lb → lu (Luxembourg) -->

JavaScript Rules

Rule JS-001: Language Names Must Use Native Language

// ✅ GOOD: Native language names
languageNames: {
    'en': 'English',
    'fr': 'Français',      // ✅ Not "French"
    'de': 'Deutsch',       // ✅ Not "German"
    'lb': 'Lëtzebuergesch' // ✅ Not "Luxembourgish"
}

// ❌ BAD: English names
languageNames: {
    'en': 'English',
    'fr': 'French',        // ❌ Should be "Français"
    'de': 'German',        // ❌ Should be "Deutsch"
    'lb': 'Luxembourgish'  // ❌ Should be "Lëtzebuergesch"
}

Rule JS-002: Flag Codes Must Map Correctly

// ✅ GOOD: Correct flag mappings
languageFlags: {
    'en': 'gb',  // ✅ Great Britain flag for English
    'fr': 'fr',  // ✅ France flag
    'de': 'de',  // ✅ Germany flag
    'lb': 'lu'   // ✅ Luxembourg flag
}

// ❌ BAD: Incorrect mappings
languageFlags: {
    'en': 'us',  // ❌ US flag is incorrect for general English
    'en': 'en',  // ❌ 'en' is not a valid flag code
    'lb': 'lb'   // ❌ 'lb' is not a valid flag code
}

Rule JS-003: Language API Must Use Correct Endpoint

// ✅ GOOD: Correct endpoint
fetch('/api/v1/platform/language/set', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ language: lang })
});

// ❌ BAD: Wrong endpoint or method
fetch('/api/v1/language/set', ...);     // ❌ Missing /platform
fetch('/api/v1/platform/language', ...);      // ❌ Missing /set
fetch('/api/v1/platform/language/set', { method: 'GET' });  // ❌ Should be POST

Translation File Rules

Rule TRANS-001: All Translation Keys Must Exist in All Files

static/locales/
├── en.json  # Must have ALL keys
├── fr.json  # Must have ALL keys
├── de.json  # Must have ALL keys
└── lb.json  # Must have ALL keys

Rule TRANS-002: Translation Files Must Be Valid JSON

// ✅ GOOD: Valid JSON
{
    "common": {
        "save": "Save",
        "cancel": "Cancel"
    }
}

// ❌ BAD: Trailing comma
{
    "common": {
        "save": "Save",
        "cancel": "Cancel",  // ❌ Trailing comma
    }
}

Rule TRANS-003: Use Nested Structure for Organization

// ✅ GOOD: Organized by section
{
    "common": {
        "save": "Sauvegarder",
        "cancel": "Annuler"
    },
    "store": {
        "dashboard": {
            "title": "Tableau de bord"
        }
    },
    "storefront": {
        "cart": {
            "empty": "Votre panier est vide"
        }
    }
}

// ❌ BAD: Flat structure
{
    "save": "Sauvegarder",
    "cancel": "Annuler",
    "dashboard_title": "Tableau de bord",
    "cart_empty": "Votre panier est vide"
}

Quick Reference

Language Selector Implementation Checklist

  • Function defined in appropriate JS file (storefront-layout.js or init-alpine.js)
  • Function exported to window.languageSelector
  • Uses tojson|safe filter for language array
  • Provides default values for both parameters
  • Uses native language names (Français, Deutsch, Lëtzebuergesch)
  • Uses correct flag codes (en→gb, fr→fr, de→de, lb→lu)
  • Calls /api/v1/platform/language/set with POST method
  • Reloads page after successful language change
  • Hides selector if only one language enabled (storefront)
  • Shows all languages (store dashboard)

Database Column Defaults

Table Column Type Default Nullable
stores default_language VARCHAR(5) 'fr' NO
stores dashboard_language VARCHAR(5) 'fr' NO
stores storefront_language VARCHAR(5) 'fr' NO
stores storefront_languages JSON ["fr","de","en"] NO
users preferred_language VARCHAR(5) NULL YES
customers preferred_language VARCHAR(5) NULL YES

Files Requiring Language Support

File Type Notes
static/storefront/js/storefront-layout.js JS languageSelector() for storefront
app/modules/core/static/store/js/init-alpine.js JS languageSelector() for store dashboard
app/modules/core/static/admin/js/init-alpine.js JS languageSelector() for admin
app/modules/core/static/merchant/js/init-alpine.js JS languageSelector() for merchant
app/templates/store/partials/header.html Template Store dashboard language selector
app/templates/admin/partials/header.html Template Admin language selector
app/templates/merchant/partials/header.html Template Merchant language selector
app/templates/storefront/base.html Template Storefront language selector
app/modules/core/routes/api/platform.py API Language endpoints (/api/v1/platform/language/*)
middleware/language.py Middleware Language detection per frontend type
static/locales/*.json JSON Translation files

Common Violations and Fixes

Violation: Alpine.js Expression Error

Symptom:

Alpine Expression Error: expected expression, got '}'

Cause: Inline x-data with Jinja template variable not properly escaped.

Fix: Move to function-based approach with tojson|safe.

Violation: languageFlags is not defined

Symptom:

Uncaught ReferenceError: languageFlags is not defined

Cause: languageSelector function not loaded or not exported.

Fix: Ensure function is defined in JS file and exported to window.languageSelector.

Violation: Wrong flag displayed

Symptom: US flag shown for English, or no flag for Luxembourgish.

Cause: Incorrect flag code mapping.

Fix: Use correct mappings: en→gb, fr→fr, de→de, lb→lu.


Validation

Add these checks to CI/CD pipeline:

# Validate translation files are valid JSON
python -c "import json; json.load(open('static/locales/en.json'))"
python -c "import json; json.load(open('static/locales/fr.json'))"
python -c "import json; json.load(open('static/locales/de.json'))"
python -c "import json; json.load(open('static/locales/lb.json'))"

# Check all translation keys exist in all files
python scripts/validate_translations.py

Remember: Language implementation errors cause immediate user-facing issues. Follow these rules strictly.