Skip to main content

spark/
template.rs

1//! Runtime template engine for Spark component bodies.
2//!
3//! Pipeline: read `.forge.html` → forge-codegen lowering → MiniJinja runtime render.
4//!
5//! Templates are loaded from the configured views root (default
6//! `resources/views/`) and cached after first render. Set `SPARK_VIEWS_DIR` to
7//! override (useful for tests and integration apps with non-standard layouts).
8//! Set `SPARK_TEMPLATE_RELOAD=true` to disable caching during development.
9
10use 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    // Explicit env var wins.
30    if let Ok(v) = std::env::var("SPARK_TEMPLATE_RELOAD") {
31        return v == "1" || v.eq_ignore_ascii_case("true");
32    }
33    // Default to hot-reload in development. Any APP_ENV that isn't explicitly
34    // "production" / "prod" gets per-request reload, so editing a .forge.html
35    // never requires a Rust recompile.
36    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    // "spark/counter" → resources/views/spark/counter.forge.html
42    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    // Runtime lowering: spark/sparkScripts directives emit MiniJinja-compatible
64    // function calls (spark_mount / spark_scripts) instead of Askama-flavored
65    // Rust paths. Functions are registered on the Environment in `render`.
66    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
77/// Render a Spark component template with the given JSON state as context.
78pub 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
91/// Render an inline source string (no file lookup) through the same runtime
92/// pipeline: forge-codegen lowering → MiniJinja with spark_mount / spark_scripts
93/// registered. Used by routes that build a page on the fly (e.g. the blog
94/// example's `/spark-demo`).
95pub 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
108/// Build a fresh MiniJinja environment pre-loaded with Spark's runtime
109/// functions: `spark_mount(name, props?)` and `spark_scripts()`.
110fn 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
156/// Drop the cache — used by `SPARK_TEMPLATE_RELOAD=true` paths or explicit reset.
157pub fn clear_cache() {
158    CACHE.write().clear();
159}