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::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
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.
105///
106/// Resolves `@extends` and `@include` references by pre-loading every
107/// referenced layout / partial into the MiniJinja environment using the same
108/// `load_and_lower` pipeline as the entry template. Walks the lowered output
109/// transitively, so `page.forge.html → layouts/app.forge.html → layouts/base.forge.html`
110/// all resolve correctly.
111pub 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
121/// Render an inline source string (no file lookup) through the same runtime
122/// pipeline: forge-codegen lowering → MiniJinja with spark_mount / spark_scripts
123/// registered. Used by routes that build a page on the fly (e.g. the blog
124/// example's `/spark-demo`). `@extends`/`@include` references inside the inline
125/// source are resolved against the disk views root just like `render()`.
126pub 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    // Pull in every layout/partial the inline source references before
130    // registering the entry template itself.
131    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
145/// Add `entry` and every template it transitively `@extends` or `@include`s
146/// into `env`. Idempotent within a single call: each view path is loaded once
147/// even if multiple templates reference it.
148fn 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    // Recurse into referenced templates BEFORE adding this one — MiniJinja
163    // doesn't strictly require dependency order, but failing fast on a
164    // missing layout points the error at the right file.
165    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
173/// Scan lowered MiniJinja source for `{% extends "..." %}` and
174/// `{% include "..." %}` template references. The lowering layer normalizes
175/// the path (`@extends("layouts.app")` → `{% extends "layouts/app" %}`), so
176/// what we extract here is already the view-path key.
177fn 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
198/// Build a fresh MiniJinja environment pre-loaded with Spark's runtime
199/// functions: `spark_mount(name, props?)` and `spark_scripts()`.
200fn 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    // `@vite([...])` — variadic entry list. The Askama path emits a Rust
244    // call (`::forge::vite::render(&[...])`); the MiniJinja path lowers to
245    // `{{ vite_render(...args)|safe }}` and we resolve the call here.
246    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
268/// Drop the cache — used by `SPARK_TEMPLATE_RELOAD=true` paths or explicit reset.
269pub 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        // Defensive: a half-written template shouldn't cause a panic in the
301        // scanner — just yield whatever refs DID parse cleanly.
302        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        // Write a tiny layout + page on disk under a temp views root, then
309        // render an inline source that extends the layout. This exercises
310        // the full preload_template_tree path through render_source().
311        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        // Point the renderer at our temp views root. The lock is best-effort:
321        // we set SPARK_VIEWS_DIR, render, then restore. Other tests in this
322        // crate don't share state via this env var.
323        let prev = std::env::var("SPARK_VIEWS_DIR").ok();
324        // SAFETY: tests are serialized via the `--test-threads=1` lock below,
325        // and we restore the previous value before returning.
326        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}