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 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, };
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}