1use std::collections::{HashMap, HashSet};
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 load_and_lower(view_path: &str) -> Result<String> {
74 if !reload_each_request() {
75 if let Some(cached) = CACHE.read().get(view_path) {
76 return Ok(cached.clone());
77 }
78 }
79 let raw: String = if let Some(embedded) = embedded_source(view_path) {
80 embedded.to_string()
81 } else {
82 let path = template_path(view_path);
83 std::fs::read_to_string(&path).map_err(|e| {
84 Error::Template(format!(
85 "failed to read template {}: {e}",
86 display_path(&path)
87 ))
88 })?
89 };
90 let lowered = forge_codegen::compile_source_runtime(&raw);
94 if !reload_each_request() {
95 CACHE.write().insert(view_path.to_string(), lowered.clone());
96 }
97 Ok(lowered)
98}
99
100fn display_path(p: &Path) -> String {
101 p.display().to_string()
102}
103
104pub fn render(view_path: &str, state: &serde_json::Value) -> Result<String> {
112 let mut env = build_env();
113 preload_template_tree(&mut env, view_path)?;
114 let tmpl = env
115 .get_template(view_path)
116 .map_err(|e| Error::Template(format!("template lookup `{view_path}`: {e}")))?;
117 tmpl.render(state)
118 .map_err(|e| Error::Template(format!("template render `{view_path}`: {e}")))
119}
120
121pub fn render_source(source: &str, ctx: &serde_json::Value) -> Result<String> {
127 let lowered = forge_codegen::compile_source_runtime(source);
128 let mut env = build_env();
129 let mut loaded: HashSet<String> = HashSet::new();
132 for r in scan_references(&lowered) {
133 preload_one(&mut env, &r, &mut loaded)?;
134 }
135 let entry = "__spark_inline__";
136 env.add_template_owned(entry.to_string(), lowered)
137 .map_err(|e| Error::Template(format!("inline template compile: {e}")))?;
138 let tmpl = env
139 .get_template(entry)
140 .map_err(|e| Error::Template(format!("inline template lookup: {e}")))?;
141 tmpl.render(ctx)
142 .map_err(|e| Error::Template(format!("inline template render: {e}")))
143}
144
145fn preload_template_tree(env: &mut minijinja::Environment<'static>, entry: &str) -> Result<()> {
149 let mut loaded: HashSet<String> = HashSet::new();
150 preload_one(env, entry, &mut loaded)
151}
152
153fn preload_one(
154 env: &mut minijinja::Environment<'static>,
155 view_path: &str,
156 loaded: &mut HashSet<String>,
157) -> Result<()> {
158 if !loaded.insert(view_path.to_string()) {
159 return Ok(());
160 }
161 let lowered = load_and_lower(view_path)?;
162 for r in scan_references(&lowered) {
166 preload_one(env, &r, loaded)?;
167 }
168 env.add_template_owned(view_path.to_string(), lowered)
169 .map_err(|e| Error::Template(format!("template compile `{view_path}`: {e}")))?;
170 Ok(())
171}
172
173fn scan_references(lowered: &str) -> Vec<String> {
178 let mut out = Vec::new();
179 for tag in ["extends", "include"] {
180 let open = format!("{{% {tag} \"");
181 let mut cursor = 0;
182 while let Some(i) = lowered[cursor..].find(&open) {
183 let name_start = cursor + i + open.len();
184 if let Some(end) = lowered[name_start..].find('"') {
185 let name = &lowered[name_start..name_start + end];
186 if !name.is_empty() {
187 out.push(name.to_string());
188 }
189 cursor = name_start + end + 1;
190 } else {
191 break;
192 }
193 }
194 }
195 out
196}
197
198fn build_env() -> minijinja::Environment<'static> {
201 use minijinja::value::Rest;
202 use minijinja::{Error as MjError, ErrorKind, Value as MjValue};
203
204 let mut env = minijinja::Environment::new();
205 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
206
207 env.add_function(
208 "spark_mount",
209 |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
210 let name = args
211 .first()
212 .and_then(|v| v.as_str())
213 .ok_or_else(|| {
214 MjError::new(
215 ErrorKind::InvalidOperation,
216 "spark_mount: missing component name",
217 )
218 })?
219 .to_string();
220 let props: serde_json::Value = match args.get(1) {
221 Some(v) => serde_json::to_value(v).map_err(|e| {
222 MjError::new(
223 ErrorKind::InvalidOperation,
224 format!("spark_mount: invalid props ({e})"),
225 )
226 })?,
227 None => serde_json::Value::Null,
228 };
229 match crate::render::render_mount(&name, &props) {
230 Ok(html) => Ok(MjValue::from_safe_string(html)),
231 Err(e) => Err(MjError::new(
232 ErrorKind::InvalidOperation,
233 format!("spark_mount({name}): {e}"),
234 )),
235 }
236 },
237 );
238
239 env.add_function("spark_scripts", || -> MjValue {
240 MjValue::from_safe_string(crate::render::boot_script())
241 });
242
243 env.add_function(
247 "vite_render",
248 |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
249 let mut entries: Vec<String> = Vec::with_capacity(args.len());
250 for arg in args.iter() {
251 if let Some(s) = arg.as_str() {
252 entries.push(s.to_string());
253 } else {
254 return Err(MjError::new(
255 ErrorKind::InvalidOperation,
256 format!("vite_render: expected string entry, got {:?}", arg.kind()),
257 ));
258 }
259 }
260 let refs: Vec<&str> = entries.iter().map(String::as_str).collect();
261 Ok(MjValue::from_safe_string(forge::vite::render(&refs)))
262 },
263 );
264
265 env
266}
267
268pub fn clear_cache() {
270 CACHE.write().clear();
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn scan_references_finds_extends_and_include() {
279 let src = r#"{% extends "layouts/app" %}
280 {% block content %}
281 {% include "partials/nav" %}
282 {% include "partials/footer" %}
283 {% endblock %}"#;
284 let refs = scan_references(src);
285 assert!(refs.contains(&"layouts/app".to_string()));
286 assert!(refs.contains(&"partials/nav".to_string()));
287 assert!(refs.contains(&"partials/footer".to_string()));
288 assert_eq!(refs.len(), 3);
289 }
290
291 #[test]
292 fn scan_references_handles_empty_and_no_refs() {
293 assert!(scan_references("").is_empty());
294 assert!(scan_references("<h1>plain html</h1>").is_empty());
295 assert!(scan_references(r#"{% extends "" %}"#).is_empty());
296 }
297
298 #[test]
299 fn scan_references_ignores_unclosed_quotes() {
300 let refs = scan_references(r#"{% extends "layouts/app" %} {% include "broken"#);
303 assert_eq!(refs, vec!["layouts/app"]);
304 }
305
306 #[test]
307 fn render_source_resolves_extends_via_disk() {
308 let tmp = tempfile::tempdir().unwrap();
312 let views = tmp.path().join("resources").join("views");
313 std::fs::create_dir_all(views.join("layouts")).unwrap();
314 std::fs::write(
315 views.join("layouts").join("app.forge.html"),
316 "<html><body>{% block content %}default{% endblock %}</body></html>",
317 )
318 .unwrap();
319
320 let prev = std::env::var("SPARK_VIEWS_DIR").ok();
324 unsafe {
327 std::env::set_var("SPARK_VIEWS_DIR", &views);
328 std::env::set_var("SPARK_TEMPLATE_RELOAD", "true");
329 }
330 clear_cache();
331
332 let inline =
333 r#"{% extends "layouts/app" %}{% block content %}hello {{ name }}{% endblock %}"#;
334 let out = render_source(inline, &serde_json::json!({ "name": "world" })).unwrap();
335 assert!(out.contains("hello world"), "got: {out}");
336 assert!(out.contains("<html>"), "layout wasn't applied: {out}");
337
338 unsafe {
339 if let Some(v) = prev {
340 std::env::set_var("SPARK_VIEWS_DIR", v);
341 } else {
342 std::env::remove_var("SPARK_VIEWS_DIR");
343 }
344 std::env::remove_var("SPARK_TEMPLATE_RELOAD");
345 }
346 }
347}