i18n+filtering fork - fluent-templates v2
at main 390 lines 14 kB view raw
1use axum::response::{IntoResponse, Response}; 2use axum_template::{RenderHtml, TemplateEngine}; 3use minijinja::context as template_context; 4use unic_langid::LanguageIdentifier; 5 6use crate::{ 7 http::{ 8 context::WebContext, 9 middleware_i18n::Language, 10 }, 11 i18n::gender::Gender, 12 storage::handle::model::Handle, 13}; 14 15/// Enhanced template renderer that handles i18n, HTMX, and gender context 16pub struct TemplateRenderer { 17 pub web_context: WebContext, 18 pub locale: LanguageIdentifier, 19 pub user_gender: Option<Gender>, 20 pub hx_boosted: bool, 21 pub hx_request: bool, 22} 23 24impl TemplateRenderer { 25 /// Create a new template renderer with all context 26 pub fn new( 27 web_context: WebContext, 28 language: Language, 29 user_gender: Option<Gender>, 30 hx_boosted: bool, 31 hx_request: bool, 32 ) -> Self { 33 Self { 34 web_context, 35 locale: language.0, 36 user_gender, 37 hx_boosted, 38 hx_request, 39 } 40 } 41 42 /// Render a template with automatic template selection and i18n context 43 pub fn render_template( 44 &self, 45 template_base: &str, 46 context: minijinja::Value, 47 current_handle: Option<&Handle>, 48 canonical_url: &str, 49 ) -> Response { 50 let template_name = self.select_template(template_base); 51 let enhanced_context = self.enhance_context(context, current_handle, canonical_url); 52 53 RenderHtml( 54 &template_name, 55 self.web_context.engine.clone(), 56 enhanced_context, 57 ) 58 .into_response() 59 } 60 61 /// Render an error template with proper error handling 62 pub fn render_error( 63 &self, 64 error: impl std::fmt::Display, 65 base_context: minijinja::Value, 66 ) -> Response { 67 let template_name = self.select_template("alert"); 68 let (err_bare, err_partial) = crate::errors::expand_error(error.to_string()); 69 70 tracing::warn!(error = %error, "rendering error template"); 71 72 let error_message = crate::i18n::fluent_loader::format_error( 73 &Language(self.locale.clone()), 74 &err_bare, 75 &err_partial 76 ); 77 78 // Normalize locale to lowercase for consistent template naming 79 let normalized_locale = self.locale.to_string().to_lowercase(); 80 81 // Create error context using proper minijinja context merging 82 let error_context = template_context! { 83 message => error_message, 84 locale => self.locale.to_string(), 85 current_locale => normalized_locale, 86 has_gender => self.user_gender.is_some(), 87 user_gender => self.user_gender.as_ref().map(|g| g.to_string()).unwrap_or_default(), 88 is_htmx => self.hx_request, 89 is_boosted => self.hx_boosted, 90 }; 91 92 // Merge with base context if it contains useful data 93 let final_context = if base_context.kind() == minijinja::value::ValueKind::Map { 94 minijinja::value::merge_maps([base_context, error_context]) 95 } else { 96 error_context 97 }; 98 99 RenderHtml( 100 &template_name, 101 self.web_context.engine.clone(), 102 final_context, 103 ) 104 .into_response() 105 } 106 107 /// Try to render a template, falling back to error on failure 108 pub fn try_render_template( 109 &self, 110 template_base: &str, 111 context: minijinja::Value, 112 current_handle: Option<&Handle>, 113 canonical_url: &str, 114 ) -> Result<Response, crate::http::errors::WebError> { 115 // Validate locale is supported 116 if !self.web_context.i18n_context.supports_language(&self.locale) { 117 tracing::warn!( 118 locale = %self.locale, 119 "unsupported locale requested, falling back to English" 120 ); 121 } 122 123 Ok(self.render_template(template_base, context, current_handle, canonical_url)) 124 } 125 126 /// Select the appropriate template based on HTMX state and locale 127 fn select_template(&self, base_name: &str) -> String { 128 // Use the same fallback logic as the original select_template! macro 129 select_template_with_fallback( 130 base_name, 131 &self.locale, 132 self.hx_boosted, 133 self.hx_request, 134 ) 135 } 136 137 /// Enhance template context with i18n and system variables 138 fn enhance_context( 139 &self, 140 base_context: minijinja::Value, 141 current_handle: Option<&Handle>, 142 canonical_url: &str, 143 ) -> minijinja::Value { 144 // Normalize locale to lowercase for consistent template naming 145 let normalized_locale = self.locale.to_string().to_lowercase(); 146 147 // Create enhancement context 148 let enhancement_context = template_context! { 149 language => self.locale.to_string(), 150 locale => self.locale.to_string(), 151 current_locale => normalized_locale, 152 current_handle => current_handle, 153 has_gender => self.user_gender.is_some(), 154 user_gender => self.user_gender.as_ref().map(|g| g.to_string()).unwrap_or_default(), 155 is_htmx => self.hx_request, 156 is_boosted => self.hx_boosted, 157 canonical_url => canonical_url, 158 site_base => format!("https://{}", self.web_context.config.external_base), 159 features => template_context! { 160 htmx_enabled => true, 161 i18n_enabled => true, 162 gender_support => self.user_gender.is_some(), 163 }, 164 }; 165 166 // Merge contexts if base_context is a map, otherwise use enhancement context 167 if base_context.kind() == minijinja::value::ValueKind::Map { 168 minijinja::value::merge_maps([base_context, enhancement_context]) 169 } else { 170 enhancement_context 171 } 172 } 173 174 /// Validate locale and provide fallback if needed 175 pub fn validate_locale(&self) -> LanguageIdentifier { 176 if self.web_context.i18n_context.supports_language(&self.locale) { 177 self.locale.clone() 178 } else { 179 // Fall back to English US 180 "en-US".parse().unwrap_or_else(|_| self.locale.clone()) 181 } 182 } 183 184 /// Get available locales for template use 185 pub fn get_available_locales(&self) -> Vec<String> { 186 self.web_context 187 .i18n_context 188 .supported_languages 189 .iter() 190 .map(|lang| lang.to_string()) 191 .collect() 192 } 193 194 /// Check if current locale has gender support 195 pub fn has_gender_support(&self) -> bool { 196 // For now, assume all locales support gender 197 // This can be enhanced with locale-specific gender support checks 198 true 199 } 200} 201 202/// Helper trait for extracting common template rendering parameters from request context 203pub trait TemplateContext { 204 fn current_handle(&self) -> Option<&Handle>; 205 fn canonical_url(&self, base: &str) -> String; 206 fn language(&self) -> &Language; 207 fn user_gender(&self) -> Option<Gender> { 208 None 209 } 210} 211 212/// Macro for easier template rendering with automatic context enhancement 213#[macro_export] 214macro_rules! render_template { 215 ($renderer:expr, $template:expr, $context:expr, $handle:expr, $url:expr) => { 216 $renderer.render_template($template, $context, $handle, $url) 217 }; 218 ($renderer:expr, $template:expr, $context:expr) => { 219 $renderer.render_template($template, $context, None, "") 220 }; 221} 222 223/// Macro for error template rendering with proper context 224#[macro_export] 225macro_rules! render_error { 226 ($renderer:expr, $error:expr, $context:expr) => { 227 Ok($renderer.render_error($error, $context)) 228 }; 229 ($renderer:expr, $error:expr) => { 230 Ok($renderer.render_error($error, minijinja::context!{})) 231 }; 232} 233 234/// Advanced template selection with fallback support 235pub fn select_template_with_fallback( 236 base_name: &str, 237 locale: &LanguageIdentifier, 238 hx_boosted: bool, 239 hx_request: bool, 240) -> String { 241 let locale_str = locale.to_string().to_lowercase(); 242 let fallback_locale = "en-us"; 243 244 let template_variant = if hx_boosted { 245 "bare.html" 246 } else if hx_request { 247 "partial.html" 248 } else { 249 "html" 250 }; 251 252 // Handle dotted template names (e.g., "settings.language" -> "settings.en-us.language.html") 253 let primary_template = if base_name.contains('.') { 254 let parts: Vec<&str> = base_name.split('.').collect(); 255 if parts.len() == 2 { 256 format!("{}.{}.{}.{}", parts[0], locale_str, parts[1], template_variant) 257 } else { 258 // For more complex dotted names, just append locale at the end as before 259 format!("{}.{}.{}", base_name, locale_str, template_variant) 260 } 261 } else { 262 format!("{}.{}.{}", base_name, locale_str, template_variant) 263 }; 264 265 // If the requested language is already the fallback, return it directly 266 if locale_str == fallback_locale { 267 return primary_template; 268 } 269 270 // Check if the language-specific template exists by checking the file system 271 let template_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) 272 .join("templates") 273 .join(&primary_template); 274 275 if template_path.exists() { 276 tracing::debug!(template = %primary_template, "using language-specific template"); 277 primary_template 278 } else { 279 // Fall back to English template with the same logic 280 let fallback_template = if base_name.contains('.') { 281 let parts: Vec<&str> = base_name.split('.').collect(); 282 if parts.len() == 2 { 283 format!("{}.{}.{}.{}", parts[0], fallback_locale, parts[1], template_variant) 284 } else { 285 format!("{}.{}.{}", base_name, fallback_locale, template_variant) 286 } 287 } else { 288 format!("{}.{}.{}", base_name, fallback_locale, template_variant) 289 }; 290 291 tracing::debug!( 292 requested_template = %primary_template, 293 fallback_template = %fallback_template, 294 "language-specific template not found, falling back to English" 295 ); 296 fallback_template 297 } 298} 299 300/// Utility for rendering HTMX partial responses 301pub fn render_htmx_partial<E: TemplateEngine>( 302 engine: E, 303 template_name: &str, 304 context: minijinja::Value, 305) -> Response { 306 RenderHtml(template_name, engine, context).into_response() 307} 308 309/// Utility for rendering full page responses with SEO metadata 310pub fn render_full_page<E: TemplateEngine>( 311 engine: E, 312 template_name: &str, 313 context: minijinja::Value, 314 page_title: &str, 315 page_description: &str, 316) -> Response { 317 let enhancement_context = template_context! { 318 page_title => page_title, 319 page_description => page_description, 320 meta => template_context! { 321 title => page_title, 322 description => page_description, 323 }, 324 }; 325 326 let final_context = if context.kind() == minijinja::value::ValueKind::Map { 327 minijinja::value::merge_maps([context, enhancement_context]) 328 } else { 329 enhancement_context 330 }; 331 332 RenderHtml(template_name, engine, final_context).into_response() 333} 334 335#[cfg(test)] 336mod tests { 337 use super::*; 338 use crate::i18n::gender::Gender; 339 use unic_langid::langid; 340 341 #[test] 342 fn test_template_selection() { 343 // Test basic template selection 344 let locale = langid!("en-US"); 345 346 let full_template = select_template_with_fallback("home", &locale, false, false); 347 assert_eq!(full_template, "home.en-us.html"); 348 349 let partial_template = select_template_with_fallback("home", &locale, false, true); 350 assert_eq!(partial_template, "home.en-us.partial.html"); 351 352 let bare_template = select_template_with_fallback("home", &locale, true, false); 353 assert_eq!(bare_template, "home.en-us.bare.html"); 354 } 355 356 #[test] 357 fn test_template_selection_with_dotted_names() { 358 let locale = langid!("en-US"); 359 360 // Test dotted template names like "settings.language" 361 let settings_template = select_template_with_fallback("settings.language", &locale, false, false); 362 assert_eq!(settings_template, "settings.en-us.language.html"); 363 364 let settings_partial = select_template_with_fallback("settings.language", &locale, false, true); 365 assert_eq!(settings_partial, "settings.en-us.language.partial.html"); 366 367 // Test with French locale 368 let fr_locale = langid!("fr-CA"); 369 let fr_settings = select_template_with_fallback("settings.language", &fr_locale, false, false); 370 assert_eq!(fr_settings, "settings.fr-ca.language.html"); 371 } 372 373 #[test] 374 fn test_template_selection_with_different_locale() { 375 let locale = langid!("fr-FR"); 376 377 let template = select_template_with_fallback("profile", &locale, false, false); 378 // Should fall back to English since French templates don't exist 379 assert_eq!(template, "profile.en-us.html"); 380 } 381 382 #[test] 383 fn test_gender_context() { 384 let gender = Some(Gender::Female); 385 assert!(gender.is_some()); 386 387 let gender_str = gender.as_ref().map(|g| g.to_string()).unwrap_or_default(); 388 assert_eq!(gender_str, "female"); 389 } 390}