1use std::path::{Path, PathBuf};
16
17use once_cell::sync::Lazy;
18use parking_lot::RwLock;
19use std::collections::HashMap;
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> {
106 let lowered = load_and_lower(view_path)?;
107 let env = build_env();
108 let mut env = env;
109 env.add_template("__spark_component__", &lowered)
110 .map_err(|e| Error::Template(format!("template compile: {e}")))?;
111 let tmpl = env
112 .get_template("__spark_component__")
113 .map_err(|e| Error::Template(format!("template lookup: {e}")))?;
114 tmpl.render(state)
115 .map_err(|e| Error::Template(format!("template render: {e}")))
116}
117
118pub fn render_source(source: &str, ctx: &serde_json::Value) -> Result<String> {
123 let lowered = forge_codegen::compile_source_runtime(source);
124 let env = build_env();
125 let mut env = env;
126 env.add_template("__spark_inline__", &lowered)
127 .map_err(|e| Error::Template(format!("inline template compile: {e}")))?;
128 let tmpl = env
129 .get_template("__spark_inline__")
130 .map_err(|e| Error::Template(format!("inline template lookup: {e}")))?;
131 tmpl.render(ctx)
132 .map_err(|e| Error::Template(format!("inline template render: {e}")))
133}
134
135fn build_env() -> minijinja::Environment<'static> {
138 use minijinja::value::Rest;
139 use minijinja::{Error as MjError, ErrorKind, Value as MjValue};
140
141 let mut env = minijinja::Environment::new();
142 env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
143
144 env.add_function(
145 "spark_mount",
146 |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
147 let name = args
148 .first()
149 .and_then(|v| v.as_str())
150 .ok_or_else(|| {
151 MjError::new(
152 ErrorKind::InvalidOperation,
153 "spark_mount: missing component name",
154 )
155 })?
156 .to_string();
157 let props: serde_json::Value = match args.get(1) {
158 Some(v) => serde_json::to_value(v).map_err(|e| {
159 MjError::new(
160 ErrorKind::InvalidOperation,
161 format!("spark_mount: invalid props ({e})"),
162 )
163 })?,
164 None => serde_json::Value::Null,
165 };
166 match crate::render::render_mount(&name, &props) {
167 Ok(html) => Ok(MjValue::from_safe_string(html)),
168 Err(e) => Err(MjError::new(
169 ErrorKind::InvalidOperation,
170 format!("spark_mount({name}): {e}"),
171 )),
172 }
173 },
174 );
175
176 env.add_function("spark_scripts", || -> MjValue {
177 MjValue::from_safe_string(crate::render::boot_script())
178 });
179
180 env
181}
182
183pub fn clear_cache() {
185 CACHE.write().clear();
186}