1use std::path::{Path, PathBuf};
14use std::sync::{Arc, OnceLock};
15
16use minijinja::{Environment, ErrorKind};
17
18pub fn env() -> Arc<Environment<'static>> {
23 static CELL: OnceLock<Arc<Environment<'static>>> = OnceLock::new();
24 CELL.get_or_init(|| Arc::new(environment(&TemplatingConfig::default())))
25 .clone()
26}
27
28#[derive(Clone, Debug)]
30pub struct TemplatingConfig {
31 pub overrides_root: Option<PathBuf>,
34 pub auto_reload: bool,
37}
38
39impl Default for TemplatingConfig {
40 fn default() -> Self {
41 let overrides_root = std::env::current_dir()
42 .ok()
43 .map(|cwd| cwd.join("templates"))
44 .filter(|p| p.is_dir());
45 Self {
46 overrides_root,
47 auto_reload: cfg!(debug_assertions),
48 }
49 }
50}
51
52pub fn environment(config: &TemplatingConfig) -> Environment<'static> {
56 let mut env = Environment::new();
57 env.set_auto_escape_callback(|name| {
58 if name.ends_with(".html") || name.ends_with(".htm") {
59 minijinja::AutoEscape::Html
60 } else {
61 minijinja::AutoEscape::None
62 }
63 });
64
65 let overrides_root: Option<Arc<Path>> = config.overrides_root.clone().map(Into::into);
66 let auto_reload = config.auto_reload;
67
68 if auto_reload {
69 let overrides_root = overrides_root.clone();
70 env.set_loader(move |name| load_template(name, overrides_root.as_deref()));
71 } else {
72 let resolved = resolve_all(overrides_root.as_deref());
73 env.set_loader(move |name| Ok(resolved.lookup(name).map(|s| s.to_string())));
74 }
75 env
76}
77
78fn load_template(
79 name: &str,
80 overrides_root: Option<&Path>,
81) -> Result<Option<String>, minijinja::Error> {
82 if let Some(root) = overrides_root {
83 match read_override(root, name) {
84 Ok(Some(source)) => return Ok(Some(source)),
85 Ok(None) => {}
86 Err(err) => {
87 return Err(minijinja::Error::new(
88 ErrorKind::InvalidOperation,
89 format!("reading override template {name}: {err}"),
90 ));
91 }
92 }
93 }
94 Ok(embedded(name).map(str::to_string))
95}
96
97fn read_override(root: &Path, name: &str) -> std::io::Result<Option<String>> {
98 let path = safe_join(root, name);
99 if !path.is_file() {
100 return Ok(None);
101 }
102 std::fs::read_to_string(path).map(Some)
103}
104
105fn safe_join(root: &Path, name: &str) -> PathBuf {
108 let mut out = root.to_path_buf();
109 for segment in name.split('/') {
110 if segment.is_empty() || segment == "." || segment == ".." {
111 continue;
112 }
113 out.push(segment);
114 }
115 out
116}
117
118struct ResolvedSet {
119 cache: std::collections::HashMap<&'static str, String>,
120}
121
122impl ResolvedSet {
123 fn lookup(&self, name: &str) -> Option<&str> {
124 if let Some(s) = self.cache.get(name) {
125 return Some(s.as_str());
126 }
127 embedded(name)
130 }
131}
132
133fn resolve_all(overrides_root: Option<&Path>) -> ResolvedSet {
134 let mut cache = std::collections::HashMap::with_capacity(EMBEDDED.len());
135 for (name, default_source) in EMBEDDED {
136 let source = overrides_root
137 .and_then(|root| std::fs::read_to_string(safe_join(root, name)).ok())
138 .unwrap_or_else(|| (*default_source).to_string());
139 cache.insert(*name, source);
140 }
141 ResolvedSet { cache }
142}
143
144const EMBEDDED: &[(&str, &str)] = &[
149 (
150 "base.html",
151 include_str!("../../assets/templates/base.html"),
152 ),
153 (
154 "base_admin.html",
155 include_str!("../../assets/templates/base_admin.html"),
156 ),
157 (
158 "includes/header.html",
159 include_str!("../../assets/templates/includes/header.html"),
160 ),
161 (
162 "includes/sidebar.html",
163 include_str!("../../assets/templates/includes/sidebar.html"),
164 ),
165 (
166 "includes/footer.html",
167 include_str!("../../assets/templates/includes/footer.html"),
168 ),
169 (
170 "admin/dashboard.html",
171 include_str!("../../assets/templates/admin/dashboard.html"),
172 ),
173 (
174 "admin/list.html",
175 include_str!("../../assets/templates/admin/list.html"),
176 ),
177 (
178 "admin/form.html",
179 include_str!("../../assets/templates/admin/form.html"),
180 ),
181 (
182 "admin/profile.html",
183 include_str!("../../assets/templates/admin/profile.html"),
184 ),
185 (
186 "admin/password_change.html",
187 include_str!("../../assets/templates/admin/password_change.html"),
188 ),
189 (
190 "admin/password_change_done.html",
191 include_str!("../../assets/templates/admin/password_change_done.html"),
192 ),
193 (
194 "admin/actions.html",
195 include_str!("../../assets/templates/admin/actions.html"),
196 ),
197 (
198 "admin/suggestion_review.html",
199 include_str!("../../assets/templates/admin/suggestion_review.html"),
200 ),
201 (
202 "admin/suggestion_applied.html",
203 include_str!("../../assets/templates/admin/suggestion_applied.html"),
204 ),
205 (
206 "auth/login.html",
207 include_str!("../../assets/templates/auth/login.html"),
208 ),
209 (
210 "auth/forbidden.html",
211 include_str!("../../assets/templates/auth/forbidden.html"),
212 ),
213 (
214 "auth/not_found.html",
215 include_str!("../../assets/templates/auth/not_found.html"),
216 ),
217];
218
219fn embedded(name: &str) -> Option<&'static str> {
220 EMBEDDED
221 .iter()
222 .find_map(|(n, s)| if *n == name { Some(*s) } else { None })
223}
224
225pub const BUNDLED_ASSETS: &[(&str, &str, &[u8])] = &[
232 (
233 "admin.css",
234 "text/css; charset=utf-8",
235 include_bytes!(concat!(env!("OUT_DIR"), "/admin.css")),
236 ),
237 (
238 "app.js",
239 "application/javascript; charset=utf-8",
240 include_bytes!("../../assets/static/app.js"),
241 ),
242];
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn every_embedded_template_parses() {
250 let env = environment(&TemplatingConfig {
251 overrides_root: None,
252 auto_reload: false,
253 });
254 for (name, _) in EMBEDDED {
255 env.get_template(name)
256 .unwrap_or_else(|e| panic!("embedded template {name} failed to parse: {e}"));
257 }
258 }
259
260 #[test]
261 fn dashboard_renders_with_minimum_context() {
262 let env = environment(&TemplatingConfig {
263 overrides_root: None,
264 auto_reload: false,
265 });
266 let tmpl = env.get_template("admin/dashboard.html").unwrap();
267 let out = tmpl
268 .render(minijinja::context! {
269 design => minijinja::context! { project_name => "Test" },
270 current_user => minijinja::Value::from(()),
271 sidebar_entries => Vec::<minijinja::Value>::new(),
272 dashboard_cards => Vec::<minijinja::Value>::new(),
273 })
274 .unwrap();
275 assert!(out.contains("Overview"));
280 assert!(out.contains("workspace"));
281 assert!(out.contains("Test"));
282 }
283
284 #[test]
285 fn safe_join_blocks_traversal() {
286 let root = PathBuf::from("/tmp/root");
287 assert_eq!(safe_join(&root, "../etc/passwd"), root.join("etc/passwd"));
288 assert_eq!(safe_join(&root, "./a"), root.join("a"));
289 }
290
291 #[test]
292 fn bundled_assets_are_non_empty() {
293 for (path, _ctype, bytes) in BUNDLED_ASSETS {
294 assert!(!bytes.is_empty(), "bundled asset {path} is empty");
295 }
296 }
297
298 #[test]
299 fn env_accessor_is_cached() {
300 let a = super::env();
301 let b = super::env();
302 assert!(
303 Arc::ptr_eq(&a, &b),
304 "env() should return the same Arc on repeated calls"
305 );
306 a.get_template("base.html").expect("base.html missing");
307 }
308}