- Add cookie to ADMIN resolution chain (cookie → user_pref → "en") - Add explicit MERCHANT resolution (cookie → user_pref → "fr") - Add language selector dropdown to admin and merchant headers - Add languageSelector() function to merchant init-alpine.js - Add flag-icons CSS and i18n.js setup to merchant base template - Add compact flag-based language selector to both login pages - Make lang attribute dynamic on all base and login templates - Pass current_language to login route template context - Update architecture doc with ADMIN/MERCHANT resolution priorities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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
- Supported Languages
- Language Context Flow
- Database Schema Rules
- Frontend Rules
- API Rules
- Template Rules
- JavaScript Rules
- 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 " 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/shop/js/shop-layout.jsfor storefrontstatic/store/js/init-alpine.jsfor 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
Rule API-001: Language Endpoint Must Set Cookie
# ✅ 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"
}
},
"shop": {
"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 (
shop-layout.jsorinit-alpine.js) - Function exported to
window.languageSelector - Uses
tojson|safefilter 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/setwith 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/shop/js/shop-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/shop/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.