Skip to main content

modo/template/
renderer.rs

1use axum::extract::FromRef;
2use axum::extract::FromRequestParts;
3use axum::response::Html;
4use http::request::Parts;
5
6use crate::service::AppState;
7
8use super::context::TemplateContext;
9use super::engine::Engine;
10
11/// Axum extractor for rendering MiniJinja templates.
12///
13/// `Renderer` is extracted from a handler's argument list and provides three render
14/// methods:
15///
16/// - [`html`](Renderer::html) — renders a template and returns `Html<String>`.
17/// - [`html_partial`](Renderer::html_partial) — renders the partial template when the
18///   request is an HTMX request, or the full page template otherwise.
19/// - [`string`](Renderer::string) — renders a template and returns `String`.
20///
21/// The handler's `context` argument is merged with the middleware-populated
22/// [`TemplateContext`]; handler values override middleware values on conflict.
23///
24/// # Requirements
25///
26/// - [`Engine`] must be registered in the [`crate::service::AppState`] registry.
27/// - [`TemplateContextLayer`](super::TemplateContextLayer) must be installed as a
28///   middleware layer on the router.
29///
30/// # Example
31///
32/// ```rust,no_run
33/// use modo::template::{Renderer, context};
34/// use axum::response::Html;
35///
36/// async fn home(renderer: Renderer) -> modo::Result<Html<String>> {
37///     renderer.html("pages/home.html", context! { title => "Home" })
38/// }
39/// ```
40#[derive(Clone)]
41pub struct Renderer {
42    pub(crate) engine: Engine,
43    pub(crate) context: TemplateContext,
44    pub(crate) is_htmx: bool,
45}
46
47impl Renderer {
48    /// Renders `template` with the given MiniJinja `context` merged with middleware
49    /// context, and returns `Html<String>`.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`Error`](crate::Error) if the template is not found or rendering fails.
54    pub fn html(&self, template: &str, context: minijinja::Value) -> crate::Result<Html<String>> {
55        let merged = self.context.merge(context);
56        let result = self.engine.render(template, merged)?;
57        Ok(Html(result))
58    }
59
60    /// Renders `partial` if the request was issued by HTMX, otherwise renders `page`.
61    ///
62    /// This is the primary method for HTMX-driven partial updates: the full `page`
63    /// template is used for initial page loads, while `partial` is used for subsequent
64    /// HTMX swaps.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`Error`](crate::Error) if the selected template is not found or
69    /// rendering fails.
70    pub fn html_partial(
71        &self,
72        page: &str,
73        partial: &str,
74        context: minijinja::Value,
75    ) -> crate::Result<Html<String>> {
76        let template = if self.is_htmx { partial } else { page };
77        self.html(template, context)
78    }
79
80    /// Renders `template` with the given MiniJinja `context` merged with middleware
81    /// context, and returns the raw `String` output.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`Error`](crate::Error) if the template is not found or rendering fails.
86    pub fn string(&self, template: &str, context: minijinja::Value) -> crate::Result<String> {
87        let merged = self.context.merge(context);
88        self.engine.render(template, merged)
89    }
90
91    /// Returns `true` if the current request was issued by HTMX (`HX-Request: true`).
92    pub fn is_htmx(&self) -> bool {
93        self.is_htmx
94    }
95}
96
97impl<S> FromRequestParts<S> for Renderer
98where
99    S: Send + Sync,
100    AppState: FromRef<S>,
101{
102    type Rejection = crate::Error;
103
104    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
105        let app_state = AppState::from_ref(state);
106        let engine_arc = app_state.get::<Engine>().ok_or_else(|| {
107            crate::Error::internal("Renderer requires Engine in service registry")
108        })?;
109        // Engine is Clone (wraps Arc internally), deref to get the Engine value
110        let engine = (*engine_arc).clone();
111
112        let context = parts
113            .extensions
114            .get::<TemplateContext>()
115            .cloned()
116            .ok_or_else(|| {
117                crate::Error::internal("Renderer requires TemplateContextLayer middleware")
118            })?;
119
120        let is_htmx = context.get("is_htmx").map(|v| v.is_true()).unwrap_or(false);
121
122        Ok(Renderer {
123            engine,
124            context,
125            is_htmx,
126        })
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::template::TemplateConfig;
134    use minijinja::context;
135
136    fn setup_engine(dir: &std::path::Path) -> Engine {
137        let tpl_dir = dir.join("templates");
138        let static_dir = dir.join("static");
139        std::fs::create_dir_all(&tpl_dir).unwrap();
140        std::fs::create_dir_all(&static_dir).unwrap();
141        std::fs::write(tpl_dir.join("page.html"), "Hello, {{ name }}!").unwrap();
142        std::fs::write(tpl_dir.join("partial.html"), "<div>{{ name }}</div>").unwrap();
143
144        let config = TemplateConfig {
145            templates_path: tpl_dir.to_str().unwrap().into(),
146            static_path: static_dir.to_str().unwrap().into(),
147            ..TemplateConfig::default()
148        };
149
150        Engine::builder().config(config).build().unwrap()
151    }
152
153    #[test]
154    fn html_renders_template() {
155        let dir = tempfile::tempdir().unwrap();
156        let engine = setup_engine(dir.path());
157        let ctx = TemplateContext::default();
158        let renderer = Renderer {
159            engine,
160            context: ctx,
161            is_htmx: false,
162        };
163        let result = renderer
164            .html("page.html", context! { name => "World" })
165            .unwrap();
166        assert_eq!(result.0, "Hello, World!");
167    }
168
169    #[test]
170    fn string_renders_template() {
171        let dir = tempfile::tempdir().unwrap();
172        let engine = setup_engine(dir.path());
173        let ctx = TemplateContext::default();
174        let renderer = Renderer {
175            engine,
176            context: ctx,
177            is_htmx: false,
178        };
179        let result = renderer
180            .string("page.html", context! { name => "World" })
181            .unwrap();
182        assert_eq!(result, "Hello, World!");
183    }
184
185    #[test]
186    fn html_partial_selects_page_for_non_htmx() {
187        let dir = tempfile::tempdir().unwrap();
188        let engine = setup_engine(dir.path());
189        let renderer = Renderer {
190            engine,
191            context: TemplateContext::default(),
192            is_htmx: false,
193        };
194        let result = renderer
195            .html_partial("page.html", "partial.html", context! { name => "Test" })
196            .unwrap();
197        assert_eq!(result.0, "Hello, Test!");
198    }
199
200    #[test]
201    fn html_partial_selects_partial_for_htmx() {
202        let dir = tempfile::tempdir().unwrap();
203        let engine = setup_engine(dir.path());
204        let renderer = Renderer {
205            engine,
206            context: TemplateContext::default(),
207            is_htmx: true,
208        };
209        let result = renderer
210            .html_partial("page.html", "partial.html", context! { name => "Test" })
211            .unwrap();
212        assert_eq!(result.0, "<div>Test</div>");
213    }
214
215    #[test]
216    fn is_htmx_returns_flag() {
217        let dir = tempfile::tempdir().unwrap();
218        let engine = setup_engine(dir.path());
219        let renderer = Renderer {
220            engine,
221            context: TemplateContext::default(),
222            is_htmx: true,
223        };
224        assert!(renderer.is_htmx());
225    }
226
227    #[test]
228    fn render_nonexistent_template_returns_error() {
229        let dir = tempfile::tempdir().unwrap();
230        let engine = setup_engine(dir.path());
231        let renderer = Renderer {
232            engine,
233            context: TemplateContext::default(),
234            is_htmx: false,
235        };
236        let result = renderer.html("nonexistent.html", context! {});
237        assert!(result.is_err());
238    }
239
240    #[test]
241    fn is_htmx_from_context() {
242        let dir = tempfile::tempdir().unwrap();
243        let engine = setup_engine(dir.path());
244        let mut ctx = TemplateContext::default();
245        ctx.set("is_htmx", minijinja::Value::from(true));
246        let renderer = Renderer {
247            engine,
248            context: ctx,
249            is_htmx: true, // matches what from_request_parts would set
250        };
251        assert!(renderer.is_htmx());
252    }
253
254    #[test]
255    fn context_merge_handler_wins() {
256        let dir = tempfile::tempdir().unwrap();
257        let engine = setup_engine(dir.path());
258
259        let tpl_dir = dir.path().join("templates");
260        std::fs::write(tpl_dir.join("ctx.html"), "{{ name }}").unwrap();
261
262        let mut ctx = TemplateContext::default();
263        ctx.set("name", minijinja::Value::from("middleware"));
264
265        let renderer = Renderer {
266            engine,
267            context: ctx,
268            is_htmx: false,
269        };
270        let result = renderer
271            .html("ctx.html", context! { name => "handler" })
272            .unwrap();
273        assert_eq!(result.0, "handler");
274    }
275}