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 resolve in this order:
6//!   1. Compile-time embedded source registered via `inventory` (see
7//!      `EmbeddedTemplate`). This is how single-binary distributions ship
8//!      templates without a `resources/views/` folder on disk.
9//!   2. Disk read from the configured views root (default `resources/views/`).
10//!
11//! Set `SPARK_VIEWS_DIR` to override the disk root (useful for tests and
12//! integration apps with non-standard layouts). Set `SPARK_TEMPLATE_RELOAD=true`
13//! to disable caching during development.
14
15use 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
23/// Compile-time-embedded template, registered via `inventory::submit!` from
24/// a generated file in the user's `OUT_DIR`. `view_path` matches what
25/// `spark::template::render` is called with (e.g. `"spark/counter"`); `source`
26/// is the raw `.forge.html` content — lowering still happens at runtime so the
27/// pipeline is identical to the disk-loaded path.
28pub 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    // Explicit env var wins.
53    if let Ok(v) = std::env::var("SPARK_TEMPLATE_RELOAD") {
54        return v == "1" || v.eq_ignore_ascii_case("true");
55    }
56    // Default to hot-reload in development. Any APP_ENV that isn't explicitly
57    // "production" / "prod" gets per-request reload, so editing a .forge.html
58    // never requires a Rust recompile.
59    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    // "spark/counter" → resources/views/spark/counter.forge.html
65    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    // Runtime lowering: spark/sparkScripts directives emit MiniJinja-compatible
91    // function calls (spark_mount / spark_scripts) instead of Askama-flavored
92    // Rust paths. Functions are registered on the Environment in `render`.
93    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
104/// Render a Spark component template with the given JSON state as context.
105pub 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
118/// Render an inline source string (no file lookup) through the same runtime
119/// pipeline: forge-codegen lowering → MiniJinja with spark_mount / spark_scripts
120/// registered. Used by routes that build a page on the fly (e.g. the blog
121/// example's `/spark-demo`).
122pub 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
135/// Build a fresh MiniJinja environment pre-loaded with Spark's runtime
136/// functions: `spark_mount(name, props?)` and `spark_scripts()`.
137fn 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
183/// Drop the cache — used by `SPARK_TEMPLATE_RELOAD=true` paths or explicit reset.
184pub fn clear_cache() {
185    CACHE.write().clear();
186}