forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
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}