acton_htmx/template/
mod.rs

1//! Askama template engine integration with HTMX patterns
2//!
3//! This module provides:
4//! - `HxTemplate` trait for automatic partial/full page detection
5//! - Template registry with optional caching
6//! - HTMX-aware template helpers
7//! - Integration with axum-htmx response types
8//!
9//! # Examples
10//!
11//! ```rust
12//! use askama::Template;
13//! use acton_htmx::template::HxTemplate;
14//! use axum_htmx::HxRequest;
15//!
16//! #[derive(Template)]
17//! #[template(source = "<h1>Posts</h1>{% for post in posts %}<p>{{ post }}</p>{% endfor %}", ext = "html")]
18//! struct PostsIndexTemplate {
19//!     posts: Vec<String>,
20//! }
21//!
22//! async fn index(HxRequest(is_htmx): HxRequest) -> impl axum::response::IntoResponse {
23//!     let template = PostsIndexTemplate {
24//!         posts: vec!["Post 1".to_string(), "Post 2".to_string()],
25//!     };
26//!
27//!     template.render_htmx(is_htmx)
28//! }
29//! ```
30
31use askama::Template;
32use axum::{
33    http::StatusCode,
34    response::{Html, IntoResponse, Response},
35};
36
37pub mod extractor;
38pub mod framework;
39pub mod helpers;
40pub mod registry;
41
42pub use extractor::*;
43pub use framework::{FrameworkTemplateError, FrameworkTemplates};
44pub use helpers::*;
45pub use registry::TemplateRegistry;
46
47/// Extension trait for Askama templates with HTMX support
48///
49/// Automatically renders partial content for HTMX requests and full pages
50/// for regular browser requests.
51pub trait HxTemplate: Template {
52    /// Render template based on HTMX request detection
53    ///
54    /// Returns partial content if `is_htmx` is true, otherwise returns full page.
55    /// The distinction between partial and full is determined by the template's
56    /// structure and naming conventions.
57    ///
58    /// # Errors
59    ///
60    /// Returns `StatusCode::INTERNAL_SERVER_ERROR` if template rendering fails.
61    fn render_htmx(self, is_htmx: bool) -> Response
62    where
63        Self: Sized,
64    {
65        match self.render() {
66            Ok(html) => {
67                if is_htmx {
68                    // For HTMX requests, extract and return just the main content
69                    let partial = extractor::extract_main_content(&html);
70                    Html(partial.into_owned()).into_response()
71                } else {
72                    // For regular requests, return the full page
73                    Html(html).into_response()
74                }
75            }
76            Err(err) => {
77                tracing::error!("Template rendering error: {}", err);
78                (
79                    StatusCode::INTERNAL_SERVER_ERROR,
80                    "Template rendering failed",
81                )
82                    .into_response()
83            }
84        }
85    }
86
87    /// Render as HTML response
88    ///
89    /// Always renders the full template regardless of request type.
90    ///
91    /// # Errors
92    ///
93    /// Returns `StatusCode::INTERNAL_SERVER_ERROR` if template rendering fails.
94    fn render_html(self) -> Response
95    where
96        Self: Sized,
97    {
98        match self.render() {
99            Ok(html) => Html(html).into_response(),
100            Err(err) => {
101                tracing::error!("Template rendering error: {}", err);
102                (
103                    StatusCode::INTERNAL_SERVER_ERROR,
104                    "Template rendering failed",
105                )
106                    .into_response()
107            }
108        }
109    }
110
111    /// Render partial content only
112    ///
113    /// Extracts and renders only the main content block without layout.
114    /// Useful for HTMX partial updates.
115    ///
116    /// # Errors
117    ///
118    /// Returns `StatusCode::INTERNAL_SERVER_ERROR` if template rendering fails.
119    fn render_partial(self) -> Response
120    where
121        Self: Sized,
122    {
123        match self.render() {
124            Ok(html) => {
125                let partial = extractor::extract_main_content(&html);
126                Html(partial.into_owned()).into_response()
127            }
128            Err(err) => {
129                tracing::error!("Template rendering error: {}", err);
130                (
131                    StatusCode::INTERNAL_SERVER_ERROR,
132                    "Template rendering failed",
133                )
134                    .into_response()
135            }
136        }
137    }
138
139    /// Render as out-of-band swap content
140    ///
141    /// Wraps the template content in an element with `hx-swap-oob="true"`.
142    /// Used for updating multiple parts of the page in a single response.
143    ///
144    /// # Arguments
145    ///
146    /// * `target_id` - The ID of the element to swap
147    /// * `swap_strategy` - The swap strategy (defaults to "true" for innerHTML)
148    ///
149    /// # Examples
150    ///
151    /// ```rust
152    /// use askama::Template;
153    /// use acton_htmx::template::HxTemplate;
154    ///
155    /// #[derive(Template)]
156    /// #[template(source = "<span>Updated: {{ count }}</span>", ext = "html")]
157    /// struct CounterTemplate { count: i32 }
158    ///
159    /// let template = CounterTemplate { count: 42 };
160    /// // Returns: <div id="counter" hx-swap-oob="true"><span>Updated: 42</span></div>
161    /// let oob_html = template.render_oob("counter", None);
162    /// ```
163    fn render_oob(self, target_id: &str, swap_strategy: Option<&str>) -> Response
164    where
165        Self: Sized,
166    {
167        match self.render() {
168            Ok(html) => {
169                let swap_attr = swap_strategy.unwrap_or("true");
170                let oob_html = format!(
171                    r#"<div id="{target_id}" hx-swap-oob="{swap_attr}">{html}</div>"#
172                );
173                Html(oob_html).into_response()
174            }
175            Err(err) => {
176                tracing::error!("Template rendering error: {}", err);
177                (
178                    StatusCode::INTERNAL_SERVER_ERROR,
179                    "Template rendering failed",
180                )
181                    .into_response()
182            }
183        }
184    }
185
186    /// Render as out-of-band swap string (for combining with other content)
187    ///
188    /// Returns the OOB HTML as a String instead of a Response, allowing
189    /// multiple OOB swaps to be combined in a single response.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`askama::Error`] if template rendering fails due to:
194    /// - Invalid template syntax
195    /// - Missing variables or fields
196    /// - Template execution errors
197    ///
198    /// # Examples
199    ///
200    /// ```rust
201    /// use askama::Template;
202    /// use acton_htmx::template::HxTemplate;
203    ///
204    /// #[derive(Template)]
205    /// #[template(source = "{{ message }}", ext = "html")]
206    /// struct FlashTemplate { message: String }
207    ///
208    /// let flash = FlashTemplate { message: "Success!".to_string() };
209    /// let oob_str = flash.render_oob_str("flash-messages", None);
210    /// // Combine with main content for a multi-target response
211    /// ```
212    fn render_oob_str(self, target_id: &str, swap_strategy: Option<&str>) -> Result<String, askama::Error>
213    where
214        Self: Sized,
215    {
216        let html = self.render()?;
217        let swap_attr = swap_strategy.unwrap_or("true");
218        Ok(format!(
219            r#"<div id="{target_id}" hx-swap-oob="{swap_attr}">{html}</div>"#
220        ))
221    }
222}
223
224// Blanket implementation for all Askama templates
225impl<T> HxTemplate for T where T: Template {}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use askama::Template;
231
232    #[derive(Template)]
233    #[template(source = "<h1>{{ title }}</h1>", ext = "html")]
234    struct TestTemplate {
235        title: String,
236    }
237
238    #[test]
239    fn test_render_html() {
240        let template = TestTemplate {
241            title: "Hello".to_string(),
242        };
243
244        let response = template.render_html();
245        assert_eq!(response.status(), StatusCode::OK);
246    }
247
248    #[test]
249    fn test_render_htmx_full_page() {
250        let template = TestTemplate {
251            title: "Hello".to_string(),
252        };
253
254        let response = template.render_htmx(false);
255        assert_eq!(response.status(), StatusCode::OK);
256    }
257
258    #[test]
259    fn test_render_htmx_partial() {
260        let template = TestTemplate {
261            title: "Hello".to_string(),
262        };
263
264        let response = template.render_htmx(true);
265        assert_eq!(response.status(), StatusCode::OK);
266    }
267
268    #[test]
269    fn test_render_oob() {
270        let template = TestTemplate {
271            title: "Updated".to_string(),
272        };
273
274        let response = template.render_oob("my-element", None);
275        assert_eq!(response.status(), StatusCode::OK);
276    }
277
278    #[test]
279    fn test_render_oob_with_strategy() {
280        let template = TestTemplate {
281            title: "Replaced".to_string(),
282        };
283
284        let response = template.render_oob("my-element", Some("outerHTML"));
285        assert_eq!(response.status(), StatusCode::OK);
286    }
287
288    #[test]
289    fn test_render_oob_str() {
290        let template = TestTemplate {
291            title: "Content".to_string(),
292        };
293
294        let oob_str = template.render_oob_str("target-id", None).unwrap();
295        assert!(oob_str.contains(r#"id="target-id""#));
296        assert!(oob_str.contains(r#"hx-swap-oob="true""#));
297        assert!(oob_str.contains("<h1>Content</h1>"));
298    }
299
300    #[test]
301    fn test_render_oob_str_with_strategy() {
302        let template = TestTemplate {
303            title: "Content".to_string(),
304        };
305
306        let oob_str = template.render_oob_str("target-id", Some("innerHTML")).unwrap();
307        assert!(oob_str.contains(r#"hx-swap-oob="innerHTML""#));
308    }
309}