forked from
smokesignal.events/smokesignal
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(¶ms)?;
// 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.