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}