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;
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 display_path(p: &Path) -> String {
74    p.display().to_string()
75}
76
77/// Process-wide cached MiniJinja environment for production renders.
78/// Functions (`spark_mount`, `spark_scripts`, `vite_render`) are registered
79/// exactly once, at first access. The set_loader callback handles all
80/// template lookups (including `@extends` / `@include` transitively), and
81/// MiniJinja caches parsed ASTs internally — so the second render of a
82/// given view pays neither lowering nor parsing cost.
83///
84/// In reload mode (dev), [`render`] bypasses this by building a fresh env
85/// each call so template edits land without a process restart.
86static SHARED_ENV: Lazy<minijinja::Environment<'static>> = Lazy::new(build_env);
87
88/// Render a Spark component template with the given JSON state as context.
89///
90/// Uses the cached process-wide [`SHARED_ENV`] in production for zero
91/// per-call function-registration cost. In reload mode
92/// (`SPARK_TEMPLATE_RELOAD=true` or non-production `APP_ENV`), builds a
93/// fresh env each call so template edits land immediately.
94///
95/// `@extends` / `@include` references resolve via the MiniJinja loader,
96/// which calls back into [`load_for_minijinja`] for each name.
97pub fn render(view_path: &str, state: &serde_json::Value) -> Result<String> {
98    if reload_each_request() {
99        let env = build_env();
100        let tmpl = env
101            .get_template(view_path)
102            .map_err(|e| Error::Template(format!("template lookup `{view_path}`: {e}")))?;
103        tmpl.render(state)
104            .map_err(|e| Error::Template(format!("template render `{view_path}`: {e}")))
105    } else {
106        let tmpl = SHARED_ENV
107            .get_template(view_path)
108            .map_err(|e| Error::Template(format!("template lookup `{view_path}`: {e}")))?;
109        tmpl.render(state)
110            .map_err(|e| Error::Template(format!("template render `{view_path}`: {e}")))
111    }
112}
113
114/// Render an inline source string (no file lookup) through the same runtime
115/// pipeline. `@extends` / `@include` inside the inline source resolve via
116/// the loader against the disk views root, identical to [`render`].
117pub fn render_source(source: &str, ctx: &serde_json::Value) -> Result<String> {
118    let lowered = forge_codegen::compile_source_runtime(source);
119    let mut env = build_env();
120    let entry = "__spark_inline__";
121    env.add_template_owned(entry.to_string(), lowered)
122        .map_err(|e| Error::Template(format!("inline template compile: {e}")))?;
123    let tmpl = env
124        .get_template(entry)
125        .map_err(|e| Error::Template(format!("inline template lookup: {e}")))?;
126    tmpl.render(ctx)
127        .map_err(|e| Error::Template(format!("inline template render: {e}")))
128}
129
130/// Loader callback for MiniJinja: returns the lowered source for a view
131/// path, or `Ok(None)` for "not found". MiniJinja calls this on the first
132/// `get_template(name)` for any unknown name, including transitively via
133/// `{% extends "..." %}` and `{% include "..." %}`.
134fn load_for_minijinja(name: &str) -> std::result::Result<Option<String>, minijinja::Error> {
135    // Embedded source path: works without any disk presence (single-binary
136    // distributions). The runtime lowering happens here so the pipeline is
137    // identical to the disk path.
138    if let Some(embedded) = embedded_source(name) {
139        let lowered = forge_codegen::compile_source_runtime(embedded);
140        return Ok(Some(lowered));
141    }
142    // Disk fallback. `Ok(None)` signals "not found" to MiniJinja — it then
143    // raises its standard `TemplateNotFound` error pointing at the name.
144    let path = template_path(name);
145    if !path.exists() {
146        return Ok(None);
147    }
148    // Read + lower. Cache the lowered string for production hot paths.
149    if !reload_each_request() {
150        if let Some(cached) = CACHE.read().get(name) {
151            return Ok(Some(cached.clone()));
152        }
153    }
154    let raw = std::fs::read_to_string(&path).map_err(|e| {
155        minijinja::Error::new(
156            minijinja::ErrorKind::TemplateNotFound,
157            format!("read {}: {e}", display_path(&path)),
158        )
159    })?;
160    let lowered = forge_codegen::compile_source_runtime(&raw);
161    if !reload_each_request() {
162        CACHE.write().insert(name.to_string(), lowered.clone());
163    }
164    Ok(Some(lowered))
165}
166
167/// Build a MiniJinja environment with Spark's runtime functions and the
168/// template loader registered. Called once for [`SHARED_ENV`] (production)
169/// and on every render call in reload mode.
170fn build_env() -> minijinja::Environment<'static> {
171    use minijinja::value::Rest;
172    use minijinja::{Error as MjError, ErrorKind, Value as MjValue};
173
174    let mut env = minijinja::Environment::new();
175    env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
176
177    env.add_function(
178        "spark_mount",
179        |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
180            let name = args
181                .first()
182                .and_then(|v| v.as_str())
183                .ok_or_else(|| {
184                    MjError::new(
185                        ErrorKind::InvalidOperation,
186                        "spark_mount: missing component name",
187                    )
188                })?
189                .to_string();
190            let props: serde_json::Value = match args.get(1) {
191                Some(v) => serde_json::to_value(v).map_err(|e| {
192                    MjError::new(
193                        ErrorKind::InvalidOperation,
194                        format!("spark_mount: invalid props ({e})"),
195                    )
196                })?,
197                None => serde_json::Value::Null,
198            };
199            match crate::render::render_mount(&name, &props) {
200                Ok(html) => Ok(MjValue::from_safe_string(html)),
201                Err(e) => Err(MjError::new(
202                    ErrorKind::InvalidOperation,
203                    format!("spark_mount({name}): {e}"),
204                )),
205            }
206        },
207    );
208
209    env.add_function("spark_scripts", || -> MjValue {
210        MjValue::from_safe_string(crate::render::boot_script())
211    });
212
213    // `@vite([...])` — variadic entry list. The Askama path emits a Rust
214    // call (`::forge::vite::render(&[...])`); the MiniJinja path lowers to
215    // `{{ vite_render(...args)|safe }}` and we resolve the call here.
216    env.add_function(
217        "vite_render",
218        |args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
219            let mut entries: Vec<String> = Vec::with_capacity(args.len());
220            for arg in args.iter() {
221                if let Some(s) = arg.as_str() {
222                    entries.push(s.to_string());
223                } else {
224                    return Err(MjError::new(
225                        ErrorKind::InvalidOperation,
226                        format!("vite_render: expected string entry, got {:?}", arg.kind()),
227                    ));
228                }
229            }
230            let refs: Vec<&str> = entries.iter().map(String::as_str).collect();
231            Ok(MjValue::from_safe_string(forge::vite::render(&refs)))
232        },
233    );
234
235    // Template loader: MiniJinja calls this on the first `get_template(name)`
236    // for any unknown name, including transitively via `@extends` / `@include`.
237    // Parsed templates are cached inside the Environment, so this only fires
238    // once per name per env (and never in cached production renders past the
239    // first one).
240    env.set_loader(load_for_minijinja);
241
242    env
243}
244
245/// Drop the cache — used by `SPARK_TEMPLATE_RELOAD=true` paths or explicit reset.
246pub fn clear_cache() {
247    CACHE.write().clear();
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::sync::Mutex;
254
255    // The tests below mutate process-wide env vars (`SPARK_VIEWS_DIR`,
256    // `SPARK_TEMPLATE_RELOAD`). Parallel test execution would race on those,
257    // so every test grabs this mutex first to serialize against its siblings.
258    static ENV_LOCK: Mutex<()> = Mutex::new(());
259
260    /// RAII test scope: locks `ENV_LOCK`, sets the env vars for the duration
261    /// of the test, restores the previous values on drop. Keeps env state
262    /// from leaking across tests when run in parallel.
263    struct EnvScope {
264        _guard: std::sync::MutexGuard<'static, ()>,
265        prev_views: Option<String>,
266        prev_reload: Option<String>,
267    }
268
269    impl EnvScope {
270        fn new(views: &Path) -> Self {
271            let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
272            let prev_views = std::env::var("SPARK_VIEWS_DIR").ok();
273            let prev_reload = std::env::var("SPARK_TEMPLATE_RELOAD").ok();
274            // SAFETY: we hold ENV_LOCK so no concurrent reader/writer exists.
275            unsafe {
276                std::env::set_var("SPARK_VIEWS_DIR", views);
277                std::env::set_var("SPARK_TEMPLATE_RELOAD", "true");
278            }
279            clear_cache();
280            Self {
281                _guard: guard,
282                prev_views,
283                prev_reload,
284            }
285        }
286    }
287
288    impl Drop for EnvScope {
289        fn drop(&mut self) {
290            // SAFETY: still under ENV_LOCK via `_guard`.
291            unsafe {
292                match &self.prev_views {
293                    Some(v) => std::env::set_var("SPARK_VIEWS_DIR", v),
294                    None => std::env::remove_var("SPARK_VIEWS_DIR"),
295                }
296                match &self.prev_reload {
297                    Some(v) => std::env::set_var("SPARK_TEMPLATE_RELOAD", v),
298                    None => std::env::remove_var("SPARK_TEMPLATE_RELOAD"),
299                }
300            }
301        }
302    }
303
304    #[test]
305    fn loader_returns_none_for_missing_template() {
306        let tmp = tempfile::tempdir().unwrap();
307        let _scope = EnvScope::new(tmp.path());
308        let result = load_for_minijinja("does/not/exist");
309        assert!(matches!(result, Ok(None)), "got: {result:?}");
310    }
311
312    #[test]
313    fn loader_returns_some_for_existing_template() {
314        let tmp = tempfile::tempdir().unwrap();
315        let views = tmp.path().join("resources").join("views");
316        std::fs::create_dir_all(&views).unwrap();
317        std::fs::write(views.join("hello.forge.html"), "<h1>hi</h1>").unwrap();
318        let _scope = EnvScope::new(&views);
319
320        let result = load_for_minijinja("hello").unwrap();
321        let body = result.expect("expected Some, got None");
322        assert!(body.contains("<h1>hi</h1>"), "body: {body}");
323    }
324
325    #[test]
326    fn render_source_resolves_extends_via_loader() {
327        let tmp = tempfile::tempdir().unwrap();
328        let views = tmp.path().join("resources").join("views");
329        std::fs::create_dir_all(views.join("layouts")).unwrap();
330        std::fs::write(
331            views.join("layouts").join("app.forge.html"),
332            "<html><body>{% block content %}default{% endblock %}</body></html>",
333        )
334        .unwrap();
335        let _scope = EnvScope::new(&views);
336
337        let inline =
338            r#"{% extends "layouts/app" %}{% block content %}hello {{ name }}{% endblock %}"#;
339        let out = render_source(inline, &serde_json::json!({ "name": "world" })).unwrap();
340        assert!(out.contains("hello world"), "got: {out}");
341        assert!(out.contains("<html>"), "layout wasn't applied: {out}");
342    }
343
344    #[test]
345    fn render_resolves_extends_via_loader() {
346        let tmp = tempfile::tempdir().unwrap();
347        let views = tmp.path().join("resources").join("views");
348        std::fs::create_dir_all(views.join("layouts")).unwrap();
349        std::fs::write(
350            views.join("layouts").join("app.forge.html"),
351            "<html><body>{% block content %}default{% endblock %}</body></html>",
352        )
353        .unwrap();
354        std::fs::write(
355            views.join("page.forge.html"),
356            r#"{% extends "layouts/app" %}{% block content %}page: {{ slug }}{% endblock %}"#,
357        )
358        .unwrap();
359        let _scope = EnvScope::new(&views);
360
361        let out = render("page", &serde_json::json!({ "slug": "intro" })).unwrap();
362        assert!(out.contains("page: intro"), "got: {out}");
363        assert!(out.contains("<html>"), "layout missing: {out}");
364    }
365}