use std::collections::HashMap;
use std::path::{Path, PathBuf};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use crate::error::{Error, Result};
pub struct EmbeddedTemplate {
pub view_path: &'static str,
pub source: &'static str,
}
inventory::collect!(EmbeddedTemplate);
fn embedded_source(view_path: &str) -> Option<&'static str> {
inventory::iter::<EmbeddedTemplate>
.into_iter()
.find(|t| t.view_path == view_path)
.map(|t| t.source)
}
static CACHE: Lazy<RwLock<HashMap<String, String>>> = Lazy::new(|| RwLock::new(HashMap::new()));
fn views_root() -> PathBuf {
if let Ok(custom) = std::env::var("SPARK_VIEWS_DIR") {
return PathBuf::from(custom);
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
cwd.join("resources").join("views")
}
fn reload_each_request() -> bool {
if let Ok(v) = std::env::var("SPARK_TEMPLATE_RELOAD") {
return v == "1" || v.eq_ignore_ascii_case("true");
}
let env = std::env::var("APP_ENV").unwrap_or_default();
!matches!(env.as_str(), "production" | "prod")
}
fn template_path(view_path: &str) -> PathBuf {
let mut p = views_root();
for segment in view_path.split('/') {
p.push(segment);
}
p.set_extension("forge.html");
p
}
fn display_path(p: &Path) -> String {
p.display().to_string()
}
static SHARED_ENV: Lazy<minijinja::Environment<'static>> = Lazy::new(build_env);
pub fn render(view_path: &str, state: &serde_json::Value) -> Result<String> {
if reload_each_request() {
let env = build_env();
let tmpl = env
.get_template(view_path)
.map_err(|e| Error::Template(format!("template lookup `{view_path}`: {e}")))?;
tmpl.render(state)
.map_err(|e| Error::Template(format!("template render `{view_path}`: {e}")))
} else {
let tmpl = SHARED_ENV
.get_template(view_path)
.map_err(|e| Error::Template(format!("template lookup `{view_path}`: {e}")))?;
tmpl.render(state)
.map_err(|e| Error::Template(format!("template render `{view_path}`: {e}")))
}
}
pub fn render_source(source: &str, ctx: &serde_json::Value) -> Result<String> {
let lowered = forge_codegen::compile_source_runtime(source);
let mut env = build_env();
let entry = "__spark_inline__";
env.add_template_owned(entry.to_string(), lowered)
.map_err(|e| Error::Template(format!("inline template compile: {e}")))?;
let tmpl = env
.get_template(entry)
.map_err(|e| Error::Template(format!("inline template lookup: {e}")))?;
tmpl.render(ctx)
.map_err(|e| Error::Template(format!("inline template render: {e}")))
}
fn load_for_minijinja(name: &str) -> std::result::Result<Option<String>, minijinja::Error> {
if let Some(embedded) = embedded_source(name) {
let lowered = forge_codegen::compile_source_runtime(embedded);
return Ok(Some(lowered));
}
let path = template_path(name);
if !path.exists() {
return Ok(None);
}
if !reload_each_request() {
if let Some(cached) = CACHE.read().get(name) {
return Ok(Some(cached.clone()));
}
}
let raw = std::fs::read_to_string(&path).map_err(|e| {
minijinja::Error::new(
minijinja::ErrorKind::TemplateNotFound,
format!("read {}: {e}", display_path(&path)),
)
})?;
let lowered = forge_codegen::compile_source_runtime(&raw);
if !reload_each_request() {
CACHE.write().insert(name.to_string(), lowered.clone());
}
Ok(Some(lowered))
}
fn build_env() -> minijinja::Environment<'static> {
use minijinja::value::Rest;
use minijinja::{Error as MjError, ErrorKind, Value as MjValue};
let mut env = minijinja::Environment::new();
env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
env.add_function(
"spark_mount",
|args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
let name = args
.first()
.and_then(|v| v.as_str())
.ok_or_else(|| {
MjError::new(
ErrorKind::InvalidOperation,
"spark_mount: missing component name",
)
})?
.to_string();
let props: serde_json::Value = match args.get(1) {
Some(v) => serde_json::to_value(v).map_err(|e| {
MjError::new(
ErrorKind::InvalidOperation,
format!("spark_mount: invalid props ({e})"),
)
})?,
None => serde_json::Value::Null,
};
match crate::render::render_mount(&name, &props) {
Ok(html) => Ok(MjValue::from_safe_string(html)),
Err(e) => Err(MjError::new(
ErrorKind::InvalidOperation,
format!("spark_mount({name}): {e}"),
)),
}
},
);
env.add_function("spark_scripts", || -> MjValue {
MjValue::from_safe_string(crate::render::boot_script())
});
env.add_function(
"vite_render",
|args: Rest<MjValue>| -> std::result::Result<MjValue, MjError> {
let mut entries: Vec<String> = Vec::with_capacity(args.len());
for arg in args.iter() {
if let Some(s) = arg.as_str() {
entries.push(s.to_string());
} else {
return Err(MjError::new(
ErrorKind::InvalidOperation,
format!("vite_render: expected string entry, got {:?}", arg.kind()),
));
}
}
let refs: Vec<&str> = entries.iter().map(String::as_str).collect();
Ok(MjValue::from_safe_string(forge::vite::render(&refs)))
},
);
env.set_loader(load_for_minijinja);
env
}
pub fn clear_cache() {
CACHE.write().clear();
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvScope {
_guard: std::sync::MutexGuard<'static, ()>,
prev_views: Option<String>,
prev_reload: Option<String>,
}
impl EnvScope {
fn new(views: &Path) -> Self {
let guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev_views = std::env::var("SPARK_VIEWS_DIR").ok();
let prev_reload = std::env::var("SPARK_TEMPLATE_RELOAD").ok();
unsafe {
std::env::set_var("SPARK_VIEWS_DIR", views);
std::env::set_var("SPARK_TEMPLATE_RELOAD", "true");
}
clear_cache();
Self {
_guard: guard,
prev_views,
prev_reload,
}
}
}
impl Drop for EnvScope {
fn drop(&mut self) {
unsafe {
match &self.prev_views {
Some(v) => std::env::set_var("SPARK_VIEWS_DIR", v),
None => std::env::remove_var("SPARK_VIEWS_DIR"),
}
match &self.prev_reload {
Some(v) => std::env::set_var("SPARK_TEMPLATE_RELOAD", v),
None => std::env::remove_var("SPARK_TEMPLATE_RELOAD"),
}
}
}
}
#[test]
fn loader_returns_none_for_missing_template() {
let tmp = tempfile::tempdir().unwrap();
let _scope = EnvScope::new(tmp.path());
let result = load_for_minijinja("does/not/exist");
assert!(matches!(result, Ok(None)), "got: {result:?}");
}
#[test]
fn loader_returns_some_for_existing_template() {
let tmp = tempfile::tempdir().unwrap();
let views = tmp.path().join("resources").join("views");
std::fs::create_dir_all(&views).unwrap();
std::fs::write(views.join("hello.forge.html"), "<h1>hi</h1>").unwrap();
let _scope = EnvScope::new(&views);
let result = load_for_minijinja("hello").unwrap();
let body = result.expect("expected Some, got None");
assert!(body.contains("<h1>hi</h1>"), "body: {body}");
}
#[test]
fn render_source_resolves_extends_via_loader() {
let tmp = tempfile::tempdir().unwrap();
let views = tmp.path().join("resources").join("views");
std::fs::create_dir_all(views.join("layouts")).unwrap();
std::fs::write(
views.join("layouts").join("app.forge.html"),
"<html><body>{% block content %}default{% endblock %}</body></html>",
)
.unwrap();
let _scope = EnvScope::new(&views);
let inline =
r#"{% extends "layouts/app" %}{% block content %}hello {{ name }}{% endblock %}"#;
let out = render_source(inline, &serde_json::json!({ "name": "world" })).unwrap();
assert!(out.contains("hello world"), "got: {out}");
assert!(out.contains("<html>"), "layout wasn't applied: {out}");
}
#[test]
fn render_resolves_extends_via_loader() {
let tmp = tempfile::tempdir().unwrap();
let views = tmp.path().join("resources").join("views");
std::fs::create_dir_all(views.join("layouts")).unwrap();
std::fs::write(
views.join("layouts").join("app.forge.html"),
"<html><body>{% block content %}default{% endblock %}</body></html>",
)
.unwrap();
std::fs::write(
views.join("page.forge.html"),
r#"{% extends "layouts/app" %}{% block content %}page: {{ slug }}{% endblock %}"#,
)
.unwrap();
let _scope = EnvScope::new(&views);
let out = render("page", &serde_json::json!({ "slug": "intro" })).unwrap();
assert!(out.contains("page: intro"), "got: {out}");
assert!(out.contains("<html>"), "layout missing: {out}");
}
}