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 locales_dir = dir.join("locales/en");
139        let static_dir = dir.join("static");
140        std::fs::create_dir_all(&tpl_dir).unwrap();
141        std::fs::create_dir_all(&locales_dir).unwrap();
142        std::fs::create_dir_all(&static_dir).unwrap();
143        std::fs::write(tpl_dir.join("page.html"), "Hello, {{ name }}!").unwrap();
144        std::fs::write(tpl_dir.join("partial.html"), "<div>{{ name }}</div>").unwrap();
145        std::fs::write(locales_dir.join("common.yaml"), "greeting: Hello").unwrap();
146
147        let config = TemplateConfig {
148            templates_path: tpl_dir.to_str().unwrap().into(),
149            locales_path: dir.join("locales").to_str().unwrap().into(),
150            static_path: static_dir.to_str().unwrap().into(),
151            ..TemplateConfig::default()
152        };
153
154        Engine::builder().config(config).build().unwrap()
155    }
156
157    #[test]
158    fn html_renders_template() {
159        let dir = tempfile::tempdir().unwrap();
160        let engine = setup_engine(dir.path());
161        let ctx = TemplateContext::default();
162        let renderer = Renderer {
163            engine,
164            context: ctx,
165            is_htmx: false,
166        };
167        let result = renderer
168            .html("page.html", context! { name => "World" })
169            .unwrap();
170        assert_eq!(result.0, "Hello, World!");
171    }
172
173    #[test]
174    fn string_renders_template() {
175        let dir = tempfile::tempdir().unwrap();
176        let engine = setup_engine(dir.path());
177        let ctx = TemplateContext::default();
178        let renderer = Renderer {
179            engine,
180            context: ctx,
181            is_htmx: false,
182        };
183        let result = renderer
184            .string("page.html", context! { name => "World" })
185            .unwrap();
186        assert_eq!(result, "Hello, World!");
187    }
188
189    #[test]
190    fn html_partial_selects_page_for_non_htmx() {
191        let dir = tempfile::tempdir().unwrap();
192        let engine = setup_engine(dir.path());
193        let renderer = Renderer {
194            engine,
195            context: TemplateContext::default(),
196            is_htmx: false,
197        };
198        let result = renderer
199            .html_partial("page.html", "partial.html", context! { name => "Test" })
200            .unwrap();
201        assert_eq!(result.0, "Hello, Test!");
202    }
203
204    #[test]
205    fn html_partial_selects_partial_for_htmx() {
206        let dir = tempfile::tempdir().unwrap();
207        let engine = setup_engine(dir.path());
208        let renderer = Renderer {
209            engine,
210            context: TemplateContext::default(),
211            is_htmx: true,
212        };
213        let result = renderer
214            .html_partial("page.html", "partial.html", context! { name => "Test" })
215            .unwrap();
216        assert_eq!(result.0, "<div>Test</div>");
217    }
218
219    #[test]
220    fn is_htmx_returns_flag() {
221        let dir = tempfile::tempdir().unwrap();
222        let engine = setup_engine(dir.path());
223        let renderer = Renderer {
224            engine,
225            context: TemplateContext::default(),
226            is_htmx: true,
227        };
228        assert!(renderer.is_htmx());
229    }
230
231    #[test]
232    fn render_nonexistent_template_returns_error() {
233        let dir = tempfile::tempdir().unwrap();
234        let engine = setup_engine(dir.path());
235        let renderer = Renderer {
236            engine,
237            context: TemplateContext::default(),
238            is_htmx: false,
239        };
240        let result = renderer.html("nonexistent.html", context! {});
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn is_htmx_from_context() {
246        let dir = tempfile::tempdir().unwrap();
247        let engine = setup_engine(dir.path());
248        let mut ctx = TemplateContext::default();
249        ctx.set("is_htmx", minijinja::Value::from(true));
250        let renderer = Renderer {
251            engine,
252            context: ctx,
253            is_htmx: true, // matches what from_request_parts would set
254        };
255        assert!(renderer.is_htmx());
256    }
257
258    #[test]
259    fn context_merge_handler_wins() {
260        let dir = tempfile::tempdir().unwrap();
261        let engine = setup_engine(dir.path());
262
263        let tpl_dir = dir.path().join("templates");
264        std::fs::write(tpl_dir.join("ctx.html"), "{{ name }}").unwrap();
265
266        let mut ctx = TemplateContext::default();
267        ctx.set("name", minijinja::Value::from("middleware"));
268
269        let renderer = Renderer {
270            engine,
271            context: ctx,
272            is_htmx: false,
273        };
274        let result = renderer
275            .html("ctx.html", context! { name => "handler" })
276            .unwrap();
277        assert_eq!(result.0, "handler");
278    }
279}