1use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18use once_cell::sync::Lazy;
19use parking_lot::RwLock;
20
21use crate::error::{Error, Result};
22
23pub struct EmbeddedTemplate {
29 pub view_path: &'static str,
30 pub source: &'static str,
31}
32inventory::collect!(EmbeddedTemplate);
33
34fn embedded_source(view_path: &str) -> Option<&'static str> {
35 inventory::iter::<EmbeddedTemplate>
36 .into_iter()
37 .find(|t| t.view_path == view_path)
38 .map(|t| t.source)
39}
40
41static CACHE: Lazy<RwLock<HashMap<String, String>>> = Lazy::new(|| RwLock::new(HashMap::new()));
42
43fn views_root() -> PathBuf {
44 if let Ok(custom) = std::env::var("SPARK_VIEWS_DIR") {
45 return PathBuf::from(custom);
46 }
47 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
48 cwd.join("resources").join("views")
49}
50
51fn reload_each_request() -> bool {
52 if let Ok(v) = std::env::var("SPARK_TEMPLATE_RELOAD") {
54 return v == "1" || v.eq_ignore_ascii_case("true");
55 }
56 let env = std::env::var("APP_ENV").unwrap_or_default();
60 !matches!(env.as_str(), "production" | "prod")
61}
62
63fn template_path(view_path: &str) -> PathBuf {
64 let mut p = views_root();
66 for segment in view_path.split('/') {
67 p.push(segment);
68 }
69 p.set_extension("forge.html");
70 p
71}
72
73fn display_path(p: &Path) -> String {
74 p.display().to_string()
75}
76
77static SHARED_ENV: Lazy<minijinja::Environment<'static>> = Lazy::new(build_env);
87
88pub fn render(view_path: &str, state: &serde_json::Value) -> Result<String> {
98 if reload_each_request() {
99 let env = build_env();
100 let tmpl = env
101 .get_template(view_path)
102 .map_err(|e| Error::Template(format!("template lookup `{view_path}`: {e}")))?;
103 tmpl.render(state)
104 .map_err(|e| Error::Template(format!("template render `{view_path}`: {e}")))
105 } else {
106 let tmpl = SHARED_ENV
107 .get_template(view_path)
108 .map_err(|e| Error::Template(format!("template lookup `{view_path}`: {e}")))?;
109 tmpl.render(state)
110 .map_err(|e| Error::Template(format!("template render `{view_path}`: {e}")))
111 }
112}
113
114pub fn render_source(source: &str, ctx: &serde_json::Value) -> Result<String> {
118 let lowered = forge_codegen::compile_source_runtime(source);
119 let mut env = build_env();
120 let entry = "__spark_inline__";
121 env.add_template_owned(entry.to_string(), lowered)
122 .map_err(|e| Error::Template(format!("inline template compile: {e}")))?;
123 let tmpl = env
124 .get_template(entry)
125 .map_err(|e| Error::Template(format!("inline template lookup: {e}")))?;
126 tmpl.render(ctx)
127 .map_err(|e| Error::Template(format!("inline template render: {e}")))
128}
129
130fn load_for_minijinja(name: &str) -> std::result::Result<Option<String>, minijinja::Error> {
135 if let Some(embedded) = embedded_source(name) {
139 let lowered = forge_codegen::compile_source_runtime(embedded);
140 return Ok(Some(lowered));
141 }
142 let path = template_path(name);
145 if !path.exists() {
146 return Ok(None);
147 }
148 if !reload_each_request() {
150 if let Some(cached) = CACHE.read().get(name) {
151 return Ok(Some(cached.clone()));
152 }
153 }
154 let raw = std::fs::read_to_string(&path).map_err(|e| {
155 minijinja::Error::new(
156 minijinja::ErrorKind::TemplateNotFound,
157 format!("read {}: {e}", display_path(&path)),
158 )
159 })?;
160 let lowered = forge_codegen::compile_source_runtime(&raw);
161 if !reload_each_request() {
162 CACHE.write().insert(name.to_string(), lowered.clone());
163 }
164 Ok(Some(lowered))
165}
166
167fn build_env() -> minijinja::Environment<'static> {
171 use minijinja::value::Rest;
172 use minijinja::{Error as MjError, ErrorKind, Value as MjValue};
173
174 let mut env = minijinja::Environment::new();
175 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
176
177 env.add_function(
178 "spark_mount",
179 |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
180 let name = args
181 .first()
182 .and_then(|v| v.as_str())
183 .ok_or_else(|| {
184 MjError::new(
185 ErrorKind::InvalidOperation,
186 "spark_mount: missing component name",
187 )
188 })?
189 .to_string();
190 let props: serde_json::Value = match args.get(1) {
191 Some(v) => serde_json::to_value(v).map_err(|e| {
192 MjError::new(
193 ErrorKind::InvalidOperation,
194 format!("spark_mount: invalid props ({e})"),
195 )
196 })?,
197 None => serde_json::Value::Null,
198 };
199 match crate::render::render_mount(&name, &props) {
200 Ok(html) => Ok(MjValue::from_safe_string(html)),
201 Err(e) => Err(MjError::new(
202 ErrorKind::InvalidOperation,
203 format!("spark_mount({name}): {e}"),
204 )),
205 }
206 },
207 );
208
209 env.add_function("spark_scripts", || -> MjValue {
210 MjValue::from_safe_string(crate::render::boot_script())
211 });
212
213 env.add_function(
217 "vite_render",
218 |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
219 let mut entries: Vec<String> = Vec::with_capacity(args.len());
220 for arg in args.iter() {
221 if let Some(s) = arg.as_str() {
222 entries.push(s.to_string());
223 } else {
224 return Err(MjError::new(
225 ErrorKind::InvalidOperation,
226 format!("vite_render: expected string entry, got {:?}", arg.kind()),
227 ));
228 }
229 }
230 let refs: Vec<&str> = entries.iter().map(String::as_str).collect();
231 Ok(MjValue::from_safe_string(forge::vite::render(&refs)))
232 },
233 );
234
235 env.set_loader(load_for_minijinja);
241
242 env
243}
244
245pub fn clear_cache() {
247 CACHE.write().clear();
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use std::sync::Mutex;
254
255 static ENV_LOCK: Mutex<()> = Mutex::new(());
259
260 struct EnvScope {
264 _guard: std::sync::MutexGuard<'static, ()>,
265 prev_views: Option<String>,
266 prev_reload: Option<String>,
267 }
268
269 impl EnvScope {
270 fn new(views: &Path) -> Self {
271 let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
272 let prev_views = std::env::var("SPARK_VIEWS_DIR").ok();
273 let prev_reload = std::env::var("SPARK_TEMPLATE_RELOAD").ok();
274 unsafe {
276 std::env::set_var("SPARK_VIEWS_DIR", views);
277 std::env::set_var("SPARK_TEMPLATE_RELOAD", "true");
278 }
279 clear_cache();
280 Self {
281 _guard: guard,
282 prev_views,
283 prev_reload,
284 }
285 }
286 }
287
288 impl Drop for EnvScope {
289 fn drop(&mut self) {
290 unsafe {
292 match &self.prev_views {
293 Some(v) => std::env::set_var("SPARK_VIEWS_DIR", v),
294 None => std::env::remove_var("SPARK_VIEWS_DIR"),
295 }
296 match &self.prev_reload {
297 Some(v) => std::env::set_var("SPARK_TEMPLATE_RELOAD", v),
298 None => std::env::remove_var("SPARK_TEMPLATE_RELOAD"),
299 }
300 }
301 }
302 }
303
304 #[test]
305 fn loader_returns_none_for_missing_template() {
306 let tmp = tempfile::tempdir().unwrap();
307 let _scope = EnvScope::new(tmp.path());
308 let result = load_for_minijinja("does/not/exist");
309 assert!(matches!(result, Ok(None)), "got: {result:?}");
310 }
311
312 #[test]
313 fn loader_returns_some_for_existing_template() {
314 let tmp = tempfile::tempdir().unwrap();
315 let views = tmp.path().join("resources").join("views");
316 std::fs::create_dir_all(&views).unwrap();
317 std::fs::write(views.join("hello.forge.html"), "<h1>hi</h1>").unwrap();
318 let _scope = EnvScope::new(&views);
319
320 let result = load_for_minijinja("hello").unwrap();
321 let body = result.expect("expected Some, got None");
322 assert!(body.contains("<h1>hi</h1>"), "body: {body}");
323 }
324
325 #[test]
326 fn render_source_resolves_extends_via_loader() {
327 let tmp = tempfile::tempdir().unwrap();
328 let views = tmp.path().join("resources").join("views");
329 std::fs::create_dir_all(views.join("layouts")).unwrap();
330 std::fs::write(
331 views.join("layouts").join("app.forge.html"),
332 "<html><body>{% block content %}default{% endblock %}</body></html>",
333 )
334 .unwrap();
335 let _scope = EnvScope::new(&views);
336
337 let inline =
338 r#"{% extends "layouts/app" %}{% block content %}hello {{ name }}{% endblock %}"#;
339 let out = render_source(inline, &serde_json::json!({ "name": "world" })).unwrap();
340 assert!(out.contains("hello world"), "got: {out}");
341 assert!(out.contains("<html>"), "layout wasn't applied: {out}");
342 }
343
344 #[test]
345 fn render_resolves_extends_via_loader() {
346 let tmp = tempfile::tempdir().unwrap();
347 let views = tmp.path().join("resources").join("views");
348 std::fs::create_dir_all(views.join("layouts")).unwrap();
349 std::fs::write(
350 views.join("layouts").join("app.forge.html"),
351 "<html><body>{% block content %}default{% endblock %}</body></html>",
352 )
353 .unwrap();
354 std::fs::write(
355 views.join("page.forge.html"),
356 r#"{% extends "layouts/app" %}{% block content %}page: {{ slug }}{% endblock %}"#,
357 )
358 .unwrap();
359 let _scope = EnvScope::new(&views);
360
361 let out = render("page", &serde_json::json!({ "slug": "intro" })).unwrap();
362 assert!(out.contains("page: intro"), "got: {out}");
363 assert!(out.contains("<html>"), "layout missing: {out}");
364 }
365}