1use std::path::{Path, PathBuf};
11
12use once_cell::sync::Lazy;
13use parking_lot::RwLock;
14use std::collections::HashMap;
15
16use crate::error::{Error, Result};
17
18static CACHE: Lazy<RwLock<HashMap<String, String>>> = Lazy::new(|| RwLock::new(HashMap::new()));
19
20fn views_root() -> PathBuf {
21 if let Ok(custom) = std::env::var("SPARK_VIEWS_DIR") {
22 return PathBuf::from(custom);
23 }
24 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
25 cwd.join("resources").join("views")
26}
27
28fn reload_each_request() -> bool {
29 if let Ok(v) = std::env::var("SPARK_TEMPLATE_RELOAD") {
31 return v == "1" || v.eq_ignore_ascii_case("true");
32 }
33 let env = std::env::var("APP_ENV").unwrap_or_default();
37 !matches!(env.as_str(), "production" | "prod")
38}
39
40fn template_path(view_path: &str) -> PathBuf {
41 let mut p = views_root();
43 for segment in view_path.split('/') {
44 p.push(segment);
45 }
46 p.set_extension("forge.html");
47 p
48}
49
50fn load_and_lower(view_path: &str) -> Result<String> {
51 if !reload_each_request() {
52 if let Some(cached) = CACHE.read().get(view_path) {
53 return Ok(cached.clone());
54 }
55 }
56 let path = template_path(view_path);
57 let raw = std::fs::read_to_string(&path).map_err(|e| {
58 Error::Template(format!(
59 "failed to read template {}: {e}",
60 display_path(&path)
61 ))
62 })?;
63 let lowered = forge_codegen::compile_source_runtime(&raw);
67 if !reload_each_request() {
68 CACHE.write().insert(view_path.to_string(), lowered.clone());
69 }
70 Ok(lowered)
71}
72
73fn display_path(p: &Path) -> String {
74 p.display().to_string()
75}
76
77pub fn render(view_path: &str, state: &serde_json::Value) -> Result<String> {
79 let lowered = load_and_lower(view_path)?;
80 let env = build_env();
81 let mut env = env;
82 env.add_template("__spark_component__", &lowered)
83 .map_err(|e| Error::Template(format!("template compile: {e}")))?;
84 let tmpl = env
85 .get_template("__spark_component__")
86 .map_err(|e| Error::Template(format!("template lookup: {e}")))?;
87 tmpl.render(state)
88 .map_err(|e| Error::Template(format!("template render: {e}")))
89}
90
91pub fn render_source(source: &str, ctx: &serde_json::Value) -> Result<String> {
96 let lowered = forge_codegen::compile_source_runtime(source);
97 let env = build_env();
98 let mut env = env;
99 env.add_template("__spark_inline__", &lowered)
100 .map_err(|e| Error::Template(format!("inline template compile: {e}")))?;
101 let tmpl = env
102 .get_template("__spark_inline__")
103 .map_err(|e| Error::Template(format!("inline template lookup: {e}")))?;
104 tmpl.render(ctx)
105 .map_err(|e| Error::Template(format!("inline template render: {e}")))
106}
107
108fn build_env() -> minijinja::Environment<'static> {
111 use minijinja::value::Rest;
112 use minijinja::{Error as MjError, ErrorKind, Value as MjValue};
113
114 let mut env = minijinja::Environment::new();
115 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
116
117 env.add_function(
118 "spark_mount",
119 |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
120 let name = args
121 .first()
122 .and_then(|v| v.as_str())
123 .ok_or_else(|| {
124 MjError::new(
125 ErrorKind::InvalidOperation,
126 "spark_mount: missing component name",
127 )
128 })?
129 .to_string();
130 let props: serde_json::Value = match args.get(1) {
131 Some(v) => serde_json::to_value(v).map_err(|e| {
132 MjError::new(
133 ErrorKind::InvalidOperation,
134 format!("spark_mount: invalid props ({e})"),
135 )
136 })?,
137 None => serde_json::Value::Null,
138 };
139 match crate::render::render_mount(&name, &props) {
140 Ok(html) => Ok(MjValue::from_safe_string(html)),
141 Err(e) => Err(MjError::new(
142 ErrorKind::InvalidOperation,
143 format!("spark_mount({name}): {e}"),
144 )),
145 }
146 },
147 );
148
149 env.add_function("spark_scripts", || -> MjValue {
150 MjValue::from_safe_string(crate::render::boot_script())
151 });
152
153 env
154}
155
156pub fn clear_cache() {
158 CACHE.write().clear();
159}