i18n+filtering fork - fluent-templates v2

smokesignal Technical Documentation - I18n Architecture#

Overview#

This document provides comprehensive technical documentation for smokesignal's internationalization (i18n) system, including architecture details, template helper implementations, code examples, and integration patterns.

Architecture Overview#

High-Level Architecture#

┌─────────────────────────────────────────────────────────────────┐
│                    HTTP Request Layer                          │
├─────────────────────────────────────────────────────────────────┤
│ Language Detection Middleware (middleware_i18n.rs)             │
│ - Accept-Language header parsing                               │
│ - URL parameter detection (?lang=fr-ca)                       │
│ - Session-based language persistence                          │
│ - Fallback to default locale                                  │
├─────────────────────────────────────────────────────────────────┤
│                    Service Layer                               │
│ FilteringService with locale-aware methods:                   │
│ - filter_events_with_locale()                                 │
│ - get_facets_with_locale()                                    │
│ - filter_events_minimal_with_locale()                         │
├─────────────────────────────────────────────────────────────────┤
│                    Cache Layer                                 │
│ Locale-aware caching with keys:                               │
│ - filter:{criteria_hash}:{locale}                             │
│ - facet:{type}:{criteria_hash}:{locale}                       │
│ - translation:{key}:{locale}                                  │
├─────────────────────────────────────────────────────────────────┤
│                 Translation Engine                             │
│ fluent-templates with compile-time loading:                   │
│ - Static .ftl file loading                                    │
│ - Fallback translation resolution                             │
│ - Gender-aware content (French Canadian)                      │
├─────────────────────────────────────────────────────────────────┤
│                 Template System                                │
│ minijinja with enhanced context:                              │
│ - tr() function for translations                              │
│ - current_locale() function                                   │
│ - gender_context() function                                   │
│ - Automatic HTMX header injection                             │
└─────────────────────────────────────────────────────────────────┘

Core Components#

1. Language Detection Middleware#

File: src/http/middleware_i18n.rs

use axum::{extract::Request, middleware::Next, response::Response};
use fluent_templates::Loader;
use unic_langid::LanguageIdentifier;

#[derive(Debug, Clone)]
pub struct Language(pub LanguageIdentifier);

/// Extract language preference from request
pub async fn language_middleware(
    mut request: Request,
    next: Next,
) -> Response {
    let language = detect_language(&request);
    request.extensions_mut().insert(Language(language));
    next.run(request).await
}

/// Multi-source language detection with priority:
/// 1. URL parameter (?lang=fr-ca)
/// 2. Accept-Language header
/// 3. Session storage
/// 4. Default locale (en-us)
fn detect_language(request: &Request) -> LanguageIdentifier {
    // URL parameter has highest priority
    if let Some(lang) = extract_lang_param(request) {
        if let Ok(locale) = lang.parse() {
            return locale;
        }
    }
    
    // Parse Accept-Language header
    if let Some(header) = request.headers().get("accept-language") {
        if let Ok(header_str) = header.to_str() {
            if let Some(locale) = parse_accept_language(header_str) {
                return locale;
            }
        }
    }
    
    // Default fallback
    "en-us".parse().unwrap()
}

/// Parse Accept-Language header with quality values
/// Example: "fr-CA,fr;q=0.9,en;q=0.8" -> fr-CA
fn parse_accept_language(header: &str) -> Option<LanguageIdentifier> {
    let mut languages: Vec<(f32, LanguageIdentifier)> = Vec::new();
    
    for lang_range in header.split(',') {
        let parts: Vec<&str> = lang_range.trim().split(';').collect();
        let lang = parts[0].trim();
        
        let quality = if parts.len() > 1 && parts[1].starts_with("q=") {
            parts[1][2..].parse().unwrap_or(1.0)
        } else {
            1.0
        };
        
        if let Ok(locale) = lang.parse() {
            languages.push((quality, locale));
        }
    }
    
    // Sort by quality (highest first)
    languages.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
    
    languages.into_iter()
        .find(|(_, locale)| is_supported_locale(locale))
        .map(|(_, locale)| locale)
}

2. Locale-Aware Service Layer#

File: src/filtering/service.rs

use unic_langid::LanguageIdentifier;
use crate::filtering::{EventFilterCriteria, FilterResults, FilterOptions};

impl FilteringService {
    /// Filter events with locale-aware facets and caching
    #[instrument(skip(self, criteria))]
    pub async fn filter_events_with_locale(
        &self,
        criteria: &EventFilterCriteria,
        locale: &str,
        options: FilterOptions,
    ) -> Result<FilterResults, FilterError> {
        let language = locale.parse::<LanguageIdentifier>()
            .map_err(|e| FilterError::InvalidLocale(e.to_string()))?;
        
        // Generate locale-aware cache key
        let cache_key = format!(
            "filter:{}:{}",
            criteria.cache_hash(),
            locale
        );
        
        // Try cache first
        if let Some(ref cache) = self.cache {
            if let Ok(Some(cached)) = cache.get::<FilterResults>(&cache_key).await {
                debug!("Cache hit for filter query with locale {}", locale);
                return Ok(cached);
            }
        }
        
        // Execute query with locale-aware facets
        let events = self.query_builder
            .filter_events(criteria, &self.pool, &options)
            .await?;
        
        let facets = if options.include_facets {
            Some(self.facet_calculator
                .calculate_facets_with_locale(criteria, &language)
                .await?)
        } else {
            None
        };
        
        let results = FilterResults {
            events: self.hydrate_events(events, &options).await?,
            facets,
            total_count: facets.as_ref().map(|f| f.total_count).unwrap_or(0),
        };
        
        // Cache results with locale
        if let Some(ref cache) = self.cache {
            let _ = cache.set(&cache_key, &results, Duration::from_secs(3600)).await;
        }
        
        Ok(results)
    }
    
    /// Get facets only with locale support
    pub async fn get_facets_with_locale(
        &self,
        criteria: &EventFilterCriteria,
        locale: &str,
    ) -> Result<EventFacets, FilterError> {
        let language = locale.parse::<LanguageIdentifier>()
            .map_err(|e| FilterError::InvalidLocale(e.to_string()))?;
            
        self.facet_calculator
            .calculate_facets_with_locale(criteria, &language)
            .await
    }
}

3. Facet Calculation with I18n#

File: src/filtering/facets.rs

use fluent_templates::{Loader, StaticLoader};
use unic_langid::LanguageIdentifier;

impl FacetCalculator {
    /// Calculate facets with locale-aware display names
    pub async fn calculate_facets_with_locale(
        &self,
        criteria: &EventFilterCriteria,
        locale: &LanguageIdentifier,
    ) -> Result<EventFacets, FilterError> {
        let mut facets = EventFacets::default();
        
        // Calculate total count (locale-independent)
        facets.total_count = self.calculate_total_count(criteria).await?;
        
        // Calculate each facet type with translations
        facets.modes = self.calculate_mode_facets_with_locale(criteria, locale).await?;
        facets.statuses = self.calculate_status_facets_with_locale(criteria, locale).await?;
        facets.date_ranges = self.calculate_date_range_facets_with_locale(criteria, locale).await?;
        facets.creators = self.calculate_creator_facets(criteria).await?; // No translation needed
        
        Ok(facets)
    }
    
    /// Calculate mode facets with translated display names
    async fn calculate_mode_facets_with_locale(
        &self,
        criteria: &EventFilterCriteria,
        locale: &LanguageIdentifier,
    ) -> Result<Vec<FacetValue>, FilterError> {
        let query = r#"
            SELECT mode, COUNT(*) as count
            FROM events
            WHERE ($1::text IS NULL OR title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%')
            GROUP BY mode
            ORDER BY count DESC
        "#;
        
        let rows = sqlx::query(query)
            .bind(&criteria.search_term)
            .fetch_all(&self.pool)
            .await?;
        
        let mut facets = Vec::new();
        for row in rows {
            let mode: String = row.try_get("mode")?;
            let count: i64 = row.try_get("count")?;
            
            // Generate i18n key and display name
            let i18n_key = format!("event-mode-{}", mode.to_lowercase());
            let display_name = self.get_translation(locale, &i18n_key, None);
            
            facets.push(FacetValue {
                value: mode,
                count,
                i18n_key: Some(i18n_key),
                display_name: Some(display_name),
            });
        }
        
        Ok(facets)
    }
    
    /// Get translation for facet display name
    fn get_translation(
        &self,
        locale: &LanguageIdentifier,
        key: &str,
        args: Option<&fluent::FluentArgs>,
    ) -> String {
        LOCALES.lookup_with_args(locale, key, args.unwrap_or(&fluent::FluentArgs::new()))
            .unwrap_or_else(|| {
                // Fallback to English
                let en_locale = "en-us".parse().unwrap();
                LOCALES.lookup_with_args(&en_locale, key, args.unwrap_or(&fluent::FluentArgs::new()))
                    .unwrap_or_else(|| key.to_string())
            })
    }
}

/// Enhanced FacetValue with i18n support
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FacetValue {
    /// The actual filter value (e.g., "in_person")
    pub value: String,
    /// Number of events matching this facet
    pub count: i64,
    /// Translation key for display name (e.g., "event-mode-in-person")
    pub i18n_key: Option<String>,
    /// Pre-calculated display name in user's locale
    pub display_name: Option<String>,
}

4. Template System Integration#

File: src/http/template_renderer.rs

use minijinja::{Environment, context};
use fluent_templates::{Loader, StaticLoader};

pub struct TemplateRenderer<'a> {
    engine: &'a Environment<'a>,
    i18n_context: &'a I18nTemplateContext,
    language: Language,
    is_htmx: bool,
    gender_context: Option<&'a GenderContext>,
}

impl<'a> TemplateRenderer<'a> {
    pub fn new(
        engine: &'a Environment<'a>,
        i18n_context: &'a I18nTemplateContext,
        language: Language,
        is_htmx: bool,
    ) -> Self {
        Self {
            engine,
            i18n_context,
            language,
            is_htmx,
            gender_context: None,
        }
    }
    
    pub fn with_gender_context(mut self, gender_context: Option<&'a GenderContext>) -> Self {
        self.gender_context = gender_context;
        self
    }
    
    /// Render template with enriched context
    pub fn render<S: Serialize>(
        &self,
        template_name: &str,
        user_context: &S,
    ) -> Result<String, TemplateError> {
        // Build enriched context
        let mut enriched_context = context! {
            // User-provided context
            ..user_context,
            
            // I18n functions
            tr => self.create_tr_function(),
            current_locale => self.language.0.to_string(),
            
            // HTMX context
            is_htmx => self.is_htmx,
            htmx_headers => self.create_htmx_headers(),
            
            // Gender context (if available)
            gender_context => self.gender_context,
        };
        
        // Add gender-aware translation function if context is available
        if let Some(gender_ctx) = self.gender_context {
            enriched_context.insert("trg", self.create_gender_tr_function(gender_ctx))?;
        }
        
        // Render template
        let template = self.engine.get_template(template_name)?;
        template.render(enriched_context).map_err(TemplateError::from)
    }
    
    /// Create translation function for templates
    fn create_tr_function(&self) -> impl Fn(&str, Option<Value>) -> String + '_ {
        move |key: &str, args: Option<Value>| {
            let fluent_args = self.value_to_fluent_args(args);
            self.i18n_context.get_translation(&self.language.0, key, fluent_args.as_ref())
        }
    }
    
    /// Create gender-aware translation function
    fn create_gender_tr_function(&self, gender_ctx: &GenderContext) -> impl Fn(&str, Option<Value>) -> String + '_ {
        move |key: &str, args: Option<Value>| {
            let mut fluent_args = self.value_to_fluent_args(args).unwrap_or_default();
            
            // Add gender context to fluent args
            fluent_args.set("gender", gender_ctx.gender.as_str());
            
            self.i18n_context.get_translation(&self.language.0, key, Some(&fluent_args))
        }
    }
    
    /// Create HTMX headers for dynamic requests
    fn create_htmx_headers(&self) -> serde_json::Value {
        json!({
            "Accept-Language": self.language.0.to_string(),
            "HX-Current-Language": self.language.0.to_string()
        })
    }
}

Template Helper Functions#

Core Helper Functions#

Translation Function (tr)#

<!-- Basic translation -->
<h1>{{ tr("page-title") }}</h1>

<!-- Translation with arguments -->
<p>{{ tr("welcome-message", {"name": user.name}) }}</p>

<!-- Translation with count-based pluralization -->
<span>{{ tr("event-count", {"count": events|length}) }}</span>

Implementation:

// In fluent file (en-us/ui.ftl)
page-title = Event Management
welcome-message = Welcome, { $name }!
event-count = { $count ->
    [one] { $count } event
   *[other] { $count } events
}

Gender-Aware Translation (trg)#

<!-- French Canadian with gender context -->
<p>{{ trg("created-by-message", {"creator": event.creator_name}) }}</p>

Implementation:

// In fluent file (fr-ca/ui.ftl)
created-by-message = { $gender ->
    [masculine] Créé par { $creator }
    [feminine] Créée par { $creator }
   *[other] Créé par { $creator }
}

Locale Detection (current_locale)#

<!-- Conditional content based on locale -->
{% if current_locale == "fr-ca" %}
    <div class="french-content">
        {{ tr("special-french-message") }}
    </div>
{% endif %}

<!-- Locale-specific styling -->
<body class="locale-{{ current_locale }}">

HTMX Language Headers (htmx_headers)#

<!-- HTMX requests with proper language context -->
<form hx-get="/events" 
      hx-target="#results"
      hx-headers='{{ htmx_headers|tojson }}'>
    <!-- Form content -->
</form>

<!-- Dynamic content loading -->
<div hx-get="/facets" 
     hx-trigger="load"
     hx-headers='{"Accept-Language": "{{ current_locale }}"}'>
</div>

Advanced Template Patterns#

Facet Rendering with Translations#

<!-- Template: templates/filter_facets.html -->
<div class="facets">
    {% for facet_group in facets %}
        <div class="facet-group">
            <h3>{{ tr(facet_group.label_key) }}</h3>
            {% for facet in facet_group.values %}
                <label class="facet-option">
                    <input type="checkbox" 
                           name="{{ facet_group.name }}" 
                           value="{{ facet.value }}">
                    
                    <!-- Use pre-calculated display name or fall back to translation -->
                    <span class="facet-label">
                        {{ facet.display_name | default(tr(facet.i18n_key)) }}
                    </span>
                    
                    <!-- Translated count with pluralization -->
                    <span class="facet-count">
                        {{ tr("facet-count", {"count": facet.count}) }}
                    </span>
                </label>
            {% endfor %}
        </div>
    {% endfor %}
</div>

Error Handling with I18n#

<!-- Template: templates/error.html -->
<div class="error-container">
    <h1>{{ tr("error-title") }}</h1>
    
    {% if error.code %}
        <div class="error-code">
            {{ tr("error-code-prefix") }} {{ error.code }}
        </div>
    {% endif %}
    
    <div class="error-message">
        <!-- Try specific error translation first -->
        {% set error_key = "error-" ~ error.type %}
        {% set translated_error = tr(error_key) %}
        
        {% if translated_error != error_key %}
            {{ translated_error }}
        {% else %}
            <!-- Fallback to generic error message -->
            {{ tr("error-generic") }}
        {% endif %}
    </div>
    
    <div class="error-actions">
        <a href="/" class="button">{{ tr("error-home-link") }}</a>
        <button onclick="history.back()" class="button secondary">
            {{ tr("error-back-link") }}
        </button>
    </div>
</div>

Locale Switcher Component#

<!-- Template: templates/components/locale_switcher.html -->
<div class="locale-switcher">
    <button class="locale-trigger" aria-expanded="false">
        <span class="current-locale">{{ current_locale|upper }}</span>
        <svg class="chevron" aria-hidden="true"><!-- chevron icon --></svg>
    </button>
    
    <ul class="locale-menu" hidden>
        {% for locale in supported_locales %}
            {% if locale != current_locale %}
                <li>
                    <a href="{{ current_url }}?lang={{ locale }}" 
                       class="locale-option"
                       hx-get="{{ current_url }}?lang={{ locale }}"
                       hx-headers='{"Accept-Language": "{{ locale }}"}'>
                        <span class="locale-code">{{ locale|upper }}</span>
                        <span class="locale-name">{{ tr("locale-name-" ~ locale) }}</span>
                    </a>
                </li>
            {% endif %}
        {% endfor %}
    </ul>
</div>

Code Examples#

HTTP Handler Integration#

use axum::{extract::Extension, response::IntoResponse};
use crate::http::{template_renderer::TemplateRenderer, middleware_i18n::Language};

/// Event filtering handler with full i18n support
#[instrument(skip(ctx, filtering_service))]
pub async fn handle_filter_events(
    Extension(ctx): Extension<WebContext>,
    Extension(filtering_service): Extension<Arc<FilteringService>>,
    language: Language,
    Query(params): Query<FilterParams>,
    headers: HeaderMap,
) -> Result<impl IntoResponse, WebError> {
    let is_htmx = headers.contains_key("hx-request");
    
    // Parse filter criteria
    let criteria = EventFilterCriteria::from_params(&params)?;
    
    // Get filtered results with locale support
    let results = filtering_service
        .filter_events_with_locale(&criteria, &language.0.to_string(), FilterOptions::default())
        .await?;
    
    // Create template renderer with i18n context
    let renderer = TemplateRenderer::new(
        &ctx.template_engine,
        &ctx.i18n_context,
        language,
        is_htmx
    );
    
    // Choose template based on request type
    let template_name = if is_htmx {
        "filter_events_results.html"
    } else {
        "filter_events.html"
    };
    
    // Render with enriched context
    let html = renderer.render(template_name, &context! {
        events => results.events,
        facets => results.facets,
        criteria => criteria,
        total_count => results.total_count,
    })?;
    
    Ok(Html(html))
}

Service Layer Integration#

use crate::filtering::{FilteringService, EventFilterCriteria};

impl FilteringService {
    /// Example: Auto-complete search with locale support
    pub async fn search_events_autocomplete(
        &self,
        query: &str,
        locale: &str,
        limit: usize,
    ) -> Result<Vec<EventSuggestion>, FilterError> {
        let language = locale.parse::<LanguageIdentifier>()?;
        
        // Use minimal filtering for performance
        let criteria = EventFilterCriteria {
            search_term: Some(query.to_string()),
            limit: Some(limit),
            ..Default::default()
        };
        
        let results = self.filter_events_minimal_with_locale(
            &criteria,
            locale,
        ).await?;
        
        // Transform to suggestions with locale-aware snippets
        let suggestions = results.events.into_iter()
            .map(|event| EventSuggestion {
                id: event.id,
                title: event.title,
                snippet: self.generate_snippet(&event, &language),
                match_type: self.detect_match_type(query, &event),
            })
            .collect();
        
        Ok(suggestions)
    }
    
    /// Generate locale-aware search snippet
    fn generate_snippet(&self, event: &Event, locale: &LanguageIdentifier) -> String {
        let template = match locale.language().as_str() {
            "fr" => "{{ event.title }} - {{ tr('event-on') }} {{ event.start_date|date('d/m/Y') }}",
            _ => "{{ event.title }} - {{ tr('event-on') }} {{ event.start_date|date('m/d/Y') }}",
        };
        
        // Render snippet template
        self.render_snippet_template(template, event, locale)
    }
}

Caching Strategies#

use redis::AsyncCommands;

impl CacheService {
    /// Locale-aware cache key generation
    pub fn generate_cache_key(&self, base_key: &str, locale: &str, params: &[&str]) -> String {
        let mut key = format!("{}:{}:{}", self.prefix, locale, base_key);
        
        for param in params {
            key.push(':');
            key.push_str(param);
        }
        
        // Add cache version for invalidation
        key.push(':');
        key.push_str(&self.cache_version);
        
        key
    }
    
    /// Cache with locale-specific TTL
    pub async fn set_with_locale<T: Serialize>(
        &self,
        key: &str,
        value: &T,
        locale: &str,
        ttl: Duration,
    ) -> Result<(), CacheError> {
        let cache_key = self.generate_cache_key(key, locale, &[]);
        
        // Locale-specific TTL (French content might be cached longer)
        let adjusted_ttl = match locale {
            "fr-ca" => ttl + Duration::from_secs(1800), // +30 minutes
            _ => ttl,
        };
        
        let serialized = serde_json::to_string(value)?;
        
        self.redis.set_ex(cache_key, serialized, adjusted_ttl.as_secs()).await?;
        
        Ok(())
    }
    
    /// Bulk cache invalidation by locale
    pub async fn invalidate_locale(&self, locale: &str, pattern: &str) -> Result<u64, CacheError> {
        let search_pattern = format!("{}:{}:{}*", self.prefix, locale, pattern);
        
        let keys: Vec<String> = self.redis.keys(search_pattern).await?;
        
        if !keys.is_empty() {
            let deleted: u64 = self.redis.del(keys).await?;
            info!("Invalidated {} cache entries for locale {}", deleted, locale);
            Ok(deleted)
        } else {
            Ok(0)
        }
    }
}

Performance Optimizations#

Translation Loading#

use fluent_templates::{StaticLoader, Loader};
use once_cell::sync::Lazy;

// Compile-time translation loading for zero runtime overhead
static LOCALES: Lazy<StaticLoader> = Lazy::new(|| {
    StaticLoader::new(&[
        ("en-us", include_str!("../i18n/en-us/ui.ftl")),
        ("fr-ca", include_str!("../i18n/fr-ca/ui.ftl")),
        // Add more locales as needed
    ]).unwrap()
});

/// High-performance translation lookup with caching
pub fn get_translation_cached(
    locale: &LanguageIdentifier,
    key: &str,
    args: Option<&fluent::FluentArgs>,
) -> String {
    // Use thread-local cache for frequently accessed translations
    thread_local! {
        static CACHE: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
    }
    
    let cache_key = format!("{}:{}", locale, key);
    
    CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        
        if let Some(cached) = cache.get(&cache_key) {
            return cached.clone();
        }
        
        let translation = LOCALES.lookup_with_args(locale, key, args.unwrap_or(&fluent::FluentArgs::new()))
            .unwrap_or_else(|| key.to_string());
        
        cache.insert(cache_key, translation.clone());
        translation
    })
}

Template Compilation#

use minijinja::Environment;

/// Optimized template environment with i18n functions
pub fn create_template_environment() -> Environment<'static> {
    let mut env = Environment::new();
    
    // Pre-compile frequently used templates
    env.add_template("base.html", include_str!("../templates/base.html")).unwrap();
    env.add_template("events.html", include_str!("../templates/events.html")).unwrap();
    
    // Add optimized i18n functions
    env.add_function("tr", translation_function);
    env.add_function("trg", gender_translation_function);
    env.add_function("current_locale", locale_function);
    
    // Add performance filters
    env.add_filter("cached_date", cached_date_format);
    env.add_filter("locale_number", locale_number_format);
    
    env
}

/// Cached date formatting by locale
fn cached_date_format(value: Value, locale: &str) -> Result<String, Error> {
    use chrono::{DateTime, Utc};
    
    let datetime: DateTime<Utc> = value.try_into()?;
    
    match locale {
        "fr-ca" => Ok(datetime.format("%d/%m/%Y à %H:%M").to_string()),
        _ => Ok(datetime.format("%m/%d/%Y at %I:%M %p").to_string()),
    }
}

This technical documentation provides a comprehensive overview of smokesignal's i18n architecture, with practical code examples and implementation patterns for maintaining and extending the internationalization system.