modo/template/
renderer.rs1use 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#[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 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 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 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 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 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, };
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}