Skip to main content

doido_view/
tera_engine.rs

1use crate::engine::TemplateEngine;
2use doido_core::{anyhow::Context as _, Result};
3use std::path::{Path, PathBuf};
4use std::sync::RwLock;
5
6pub struct TeraEngine {
7    tera: RwLock<tera::Tera>,
8    templates_dir: String,
9}
10
11impl TeraEngine {
12    pub fn new(templates_dir: &str) -> Result<Self> {
13        let tera = load(templates_dir)
14            .with_context(|| format!("failed to load templates from {templates_dir}"))?;
15        Ok(Self {
16            tera: RwLock::new(tera),
17            templates_dir: templates_dir.to_string(),
18        })
19    }
20}
21
22impl TemplateEngine for TeraEngine {
23    fn render(&self, template: &str, context: &serde_json::Value) -> Result<String> {
24        let template_name = format!("{}.html.tera", template);
25        let ctx = tera::Context::from_serialize(context)
26            .map_err(|e| doido_core::anyhow::anyhow!("invalid template context: {e}"))?;
27        self.tera
28            .read()
29            .unwrap()
30            .render(&template_name, &ctx)
31            .map_err(|e| doido_core::anyhow::anyhow!("template '{}' render failed: {e}", template))
32    }
33
34    fn render_named(&self, name: &str, context: &serde_json::Value) -> Result<String> {
35        let ctx = tera::Context::from_serialize(context)
36            .map_err(|e| doido_core::anyhow::anyhow!("invalid template context: {e}"))?;
37        self.tera
38            .read()
39            .unwrap()
40            .render(name, &ctx)
41            .map_err(|e| doido_core::anyhow::anyhow!("template '{}' render failed: {e}", name))
42    }
43
44    fn reload(&self) -> Result<()> {
45        let tera = load(&self.templates_dir)
46            .with_context(|| format!("reload failed for {}", self.templates_dir))?;
47        *self.tera.write().unwrap() = tera;
48        Ok(())
49    }
50}
51
52/// Load every `*.tera` file under `dir` into a Tera instance, keyed by the file's
53/// path relative to `dir` (so `dir/posts/index.html.tera` registers as
54/// `posts/index.html.tera`). Tera 2 dropped the glob constructor, so we walk the
55/// tree ourselves and add every template in one call (which resolves inheritance
56/// across the whole set regardless of insertion order).
57///
58/// Framework-provided templates (registered via
59/// [`crate::global::register_framework_template`] — e.g. `doido-auth`'s built-in
60/// auth views) are loaded first, then app templates override any of the same name.
61/// Both sets are added in a single `add_raw_templates` call so template
62/// inheritance resolves across them (a framework view may `extends` an app layout).
63fn load(dir: &str) -> Result<tera::Tera> {
64    let base = Path::new(dir);
65    let mut files: Vec<(PathBuf, String)> = Vec::new();
66    if base.exists() {
67        collect(base, base, &mut files)?;
68    }
69
70    let mut app: Vec<(String, String)> = Vec::with_capacity(files.len());
71    for (path, name) in &files {
72        let content = std::fs::read_to_string(path)
73            .with_context(|| format!("reading template {}", path.display()))?;
74        app.push((name.clone(), content));
75    }
76
77    let framework = crate::global::framework_template_snapshot();
78    match build(&framework, &app) {
79        Ok(tera) => Ok(tera),
80        // A framework template that can't resolve (e.g. it `extends` a layout this
81        // app doesn't define) must not break the whole engine: fall back to the
82        // app's own templates only. App rendering keeps working; the built-in
83        // framework view is simply unavailable until the app provides what it needs.
84        Err(e) if !framework.is_empty() => {
85            doido_core::tracing::warn!(
86                "framework templates failed to load ({e}); using app templates only"
87            );
88            build(&[], &app)
89        }
90        Err(e) => Err(e),
91    }
92}
93
94/// Build a Tera instance from `framework` templates (loaded first, overridable)
95/// plus `app` templates (override framework ones with the same name). Both sets
96/// are added in a single call so inheritance resolves across them.
97fn build(framework: &[(String, String)], app: &[(String, String)]) -> Result<tera::Tera> {
98    let mut raw: Vec<(&str, &str)> = Vec::with_capacity(framework.len() + app.len());
99    for (name, content) in framework {
100        if app.iter().any(|(n, _)| n == name) {
101            continue; // app template overrides this framework one
102        }
103        raw.push((name.as_str(), content.as_str()));
104    }
105    for (name, content) in app {
106        raw.push((name.as_str(), content.as_str()));
107    }
108
109    let mut tera = tera::Tera::new();
110    tera.add_raw_templates(raw)
111        .map_err(|e| doido_core::anyhow::anyhow!("{e}"))?;
112    Ok(tera)
113}
114
115/// Recursively collect `*.tera` files under `dir`, pairing each with its path
116/// relative to `base` (forward-slash separated) to use as the Tera template name.
117fn collect(base: &Path, dir: &Path, out: &mut Vec<(PathBuf, String)>) -> Result<()> {
118    for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
119        let path = entry?.path();
120        if path.is_dir() {
121            collect(base, &path, out)?;
122        } else if path.extension().and_then(|e| e.to_str()) == Some("tera") {
123            let rel = path.strip_prefix(base).unwrap_or(&path);
124            let name = rel
125                .components()
126                .map(|c| c.as_os_str().to_string_lossy())
127                .collect::<Vec<_>>()
128                .join("/");
129            out.push((path.clone(), name));
130        }
131    }
132    Ok(())
133}