Skip to main content

laterite_web/theme/
engine.rs

1//! Rendering: a theme's templates compiled into one environment.
2//!
3//! An [`Engine`] holds a theme's layouts, pages, partials and assets, with
4//! every ancestor's filling in for the ones the theme lacks, as named
5//! templates: `layouts/default.htm`, `pages/home.htm`, `partials/nav.htm`,
6//! `assets/css/site.css`. A page extends a layout with `{% extends
7//! "layouts/default.htm" %}` and fills its blocks; a template includes a
8//! partial with `{% include "partials/nav.htm" %}` or renders one with its own
9//! variables through `partial("nav.htm", title="Acme")`; `content("intro.md")`
10//! places a content block, Markdown rendered to HTML. HTML is escaped
11//! everywhere but inside an asset. A printed undefined value is an error; a
12//! tested one is not.
13//!
14//! [`check`] parses a template without rendering it, so a save can refuse a
15//! template that would fail. An [`EngineCache`] keeps one engine per theme and
16//! rebuilds it when the theme or an ancestor changes.
17
18use super::{store, Kind, Theme, ThemeError};
19use laterite_core::Db;
20use minijinja::value::{Kwargs, Serde, Value};
21use minijinja::{AutoEscape, Environment, ErrorKind, State, UndefinedBehavior};
22use serde::Serialize;
23use std::collections::HashMap;
24use std::fmt;
25use std::sync::{Arc, RwLock};
26
27/// The instruction budget a render may spend, so a runaway loop ends as an
28/// error rather than a hung request.
29const FUEL: u64 = 5_000_000;
30
31/// The environment name of a template: the kind's folder and the path.
32pub fn template_name(kind: Kind, path: &str) -> String {
33    format!("{}/{path}", folder(kind))
34}
35
36fn folder(kind: Kind) -> &'static str {
37    match kind {
38        Kind::Layout => "layouts",
39        Kind::Page => "pages",
40        Kind::Partial => "partials",
41        Kind::Content => "content",
42        Kind::Asset => "assets",
43    }
44}
45
46/// What went wrong rendering or parsing, with the template and line when known.
47#[derive(Debug, Clone)]
48pub struct RenderError {
49    /// The environment name of the template the error is in.
50    pub template: Option<String>,
51    /// The line within it, from one.
52    pub line: Option<usize>,
53    /// The engine's error class, as text (`syntax error`, `undefined value`).
54    pub kind: String,
55    pub message: String,
56}
57
58impl fmt::Display for RenderError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match (&self.template, self.line) {
61            (Some(t), Some(l)) => write!(f, "{t}:{l}: {}", self.message),
62            (Some(t), None) => write!(f, "{t}: {}", self.message),
63            _ => f.write_str(&self.message),
64        }
65    }
66}
67
68impl std::error::Error for RenderError {}
69
70impl From<minijinja::Error> for RenderError {
71    fn from(e: minijinja::Error) -> Self {
72        let message = match e.detail() {
73            Some(detail) => format!("{}: {detail}", e.kind()),
74            None => e.kind().to_string(),
75        };
76        RenderError {
77            template: e.name().map(str::to_string),
78            line: e.line(),
79            kind: e.kind().to_string(),
80            message,
81        }
82    }
83}
84
85/// A theme's templates, compiled and ready to render.
86pub struct Engine {
87    env: Environment<'static>,
88    theme: Theme,
89    stamp: String,
90    names: Vec<String>,
91}
92
93/// The theme as templates see it: its settings values, then `code`, `name`,
94/// `author`, `homepage` and `description`, which a setting cannot shadow.
95fn theme_value(theme: &Theme) -> serde_json::Value {
96    let mut map = match &theme.settings {
97        serde_json::Value::Object(values) => values.clone(),
98        _ => serde_json::Map::new(),
99    };
100    map.insert("code".into(), theme.code.clone().into());
101    map.insert("name".into(), theme.config.name.clone().into());
102    map.insert("author".into(), theme.config.author.clone().into());
103    map.insert("homepage".into(), theme.config.homepage.clone().into());
104    map.insert(
105        "description".into(),
106        theme.config.description.clone().into(),
107    );
108    serde_json::Value::Object(map)
109}
110
111/// The stamp of a chain: every theme's `updated_at`, nearest first. A change
112/// to the theme or any ancestor changes it.
113fn stamp_of(chain: &[Theme]) -> String {
114    chain
115        .iter()
116        .map(|t| format!("{}@{}", t.code, t.updated_at))
117        .collect::<Vec<_>>()
118        .join(";")
119}
120
121impl Engine {
122    /// Compiles the theme `code` with its ancestors.
123    pub async fn build(db: &Db, code: &str) -> Result<Engine, ThemeError> {
124        Engine::build_with(db, code, |_| {}).await
125    }
126
127    /// Like [`Engine::build`], with a hook that adds the application's own
128    /// functions, filters and globals to the environment before the templates
129    /// compile.
130    pub async fn build_with(
131        db: &Db,
132        code: &str,
133        configure: impl FnOnce(&mut Environment<'static>),
134    ) -> Result<Engine, ThemeError> {
135        let chain = store::chain(db, code).await?;
136        let stamp = stamp_of(&chain);
137        let mut sources: HashMap<String, String> = HashMap::new();
138        let mut content: HashMap<String, String> = HashMap::new();
139        // Ancestors first, so a nearer theme's row replaces a farther one's.
140        for theme in chain.iter().rev() {
141            for t in store::list_templates(db, theme.id, None).await? {
142                if t.kind == Kind::Content {
143                    content.insert(t.path, t.markup);
144                } else {
145                    sources.insert(template_name(t.kind, &t.path), t.markup);
146                }
147            }
148        }
149        let theme = chain
150            .into_iter()
151            .next()
152            .expect("a chain starts with its theme");
153        let mut env = base_environment();
154        env.add_global("theme", Value::from(Serde(theme_value(&theme))));
155        let content = Arc::new(content);
156        env.add_function(
157            "content",
158            move |name: String| -> Result<Value, minijinja::Error> {
159                match content.get(&name) {
160                    Some(body) => Ok(Value::from_safe_string(render_content(&name, body))),
161                    None => Err(minijinja::Error::new(
162                        ErrorKind::TemplateNotFound,
163                        format!("no content block {name:?}"),
164                    )),
165                }
166            },
167        );
168        env.add_function("partial", partial);
169        configure(&mut env);
170        let mut names: Vec<String> = sources.keys().cloned().collect();
171        names.sort();
172        for (name, source) in sources {
173            env.add_template_owned(name, source)
174                .map_err(|e| ThemeError::Template(RenderError::from(e)))?;
175        }
176        Ok(Engine {
177            env,
178            theme,
179            stamp,
180            names,
181        })
182    }
183
184    /// The theme this engine renders.
185    pub fn theme(&self) -> &Theme {
186        &self.theme
187    }
188
189    /// Changes when the theme or an ancestor does; [`EngineCache`] compares it.
190    pub fn stamp(&self) -> &str {
191        &self.stamp
192    }
193
194    /// The environment, for a render path that needs more than [`Engine::render`].
195    pub fn env(&self) -> &Environment<'static> {
196        &self.env
197    }
198
199    /// Every compiled template's name, sorted.
200    pub fn names(&self) -> &[String] {
201        &self.names
202    }
203
204    /// Whether the theme, or an ancestor, has this template.
205    pub fn has(&self, kind: Kind, path: &str) -> bool {
206        self.names.binary_search(&template_name(kind, path)).is_ok()
207    }
208
209    /// Renders a template with `ctx` as its variables.
210    pub fn render(
211        &self,
212        kind: Kind,
213        path: &str,
214        ctx: impl Serialize,
215    ) -> Result<String, RenderError> {
216        let template = self.env.get_template(&template_name(kind, path))?;
217        Ok(template.render(Value::from(Serde(ctx)))?)
218    }
219}
220
221/// The environment every theme compiles into: HTML escaped outside assets,
222/// a printed undefined value an error, a fuel budget.
223fn base_environment() -> Environment<'static> {
224    let mut env = Environment::new();
225    env.set_auto_escape_callback(|name| {
226        if name.starts_with("assets/") {
227            AutoEscape::None
228        } else {
229            AutoEscape::Html
230        }
231    });
232    env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
233    env.set_fuel(Some(FUEL));
234    env
235}
236
237/// `partial("nav.htm", title="Acme")`: renders `partials/nav.htm` with the
238/// named variables and the globals.
239fn partial(state: &State, name: String, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
240    let template = state
241        .env()
242        .get_template(&template_name(Kind::Partial, &name))?;
243    let ctx = Value::from_pairs(
244        kwargs
245            .args()
246            .map(|k| (k.to_string(), kwargs.get::<Value>(k).unwrap_or_default())),
247    );
248    kwargs.assert_all_used()?;
249    Ok(Value::from_safe_string(template.render(ctx)?))
250}
251
252/// A content block as HTML: Markdown rendered (`.md`), HTML as it is (`.htm`,
253/// `.html`), anything else escaped as text.
254pub fn render_content(path: &str, body: &str) -> String {
255    let ext = path.rsplit('.').next().unwrap_or_default();
256    match ext {
257        "md" | "markdown" => {
258            let mut options = pulldown_cmark::Options::empty();
259            options.insert(pulldown_cmark::Options::ENABLE_TABLES);
260            options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
261            options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
262            let parser = pulldown_cmark::Parser::new_ext(body, options);
263            let mut html = String::with_capacity(body.len() * 2);
264            pulldown_cmark::html::push_html(&mut html, parser);
265            html
266        }
267        "htm" | "html" => body.to_string(),
268        _ => minijinja::HtmlEscape(body).to_string(),
269    }
270}
271
272/// Parses a template's markup without rendering it. The error carries the
273/// line, so an editor can point at it.
274pub fn check(source: &str) -> Result<(), RenderError> {
275    let env = base_environment();
276    env.template_from_str(source)?;
277    Ok(())
278}
279
280/// One engine per theme, rebuilt when the theme or an ancestor changes.
281#[derive(Default)]
282pub struct EngineCache {
283    engines: RwLock<HashMap<String, Arc<Engine>>>,
284}
285
286impl EngineCache {
287    pub fn new() -> Self {
288        Self::default()
289    }
290
291    /// The engine for `code`, built or rebuilt as its stamp requires.
292    pub async fn get(&self, db: &Db, code: &str) -> Result<Arc<Engine>, ThemeError> {
293        self.get_with(db, code, |_| {}).await
294    }
295
296    /// Like [`EngineCache::get`], configuring a rebuilt engine as
297    /// [`Engine::build_with`] does.
298    pub async fn get_with(
299        &self,
300        db: &Db,
301        code: &str,
302        configure: impl FnOnce(&mut Environment<'static>),
303    ) -> Result<Arc<Engine>, ThemeError> {
304        let chain = store::chain(db, code).await?;
305        let stamp = stamp_of(&chain);
306        if let Some(engine) = self.engines.read().expect("engine cache").get(code) {
307            if engine.stamp == stamp {
308                return Ok(Arc::clone(engine));
309            }
310        }
311        let engine = Arc::new(Engine::build_with(db, code, configure).await?);
312        self.engines
313            .write()
314            .expect("engine cache")
315            .insert(code.to_string(), Arc::clone(&engine));
316        Ok(engine)
317    }
318
319    /// Drops the engine for `code`, so the next request rebuilds it.
320    pub fn invalidate(&self, code: &str) {
321        self.engines.write().expect("engine cache").remove(code);
322    }
323}