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::page::{page_name, PageSettings};
19use super::router::{Params, RouteTable};
20use super::{store, Kind, Theme, ThemeError};
21use laterite_core::Db;
22use minijinja::value::{Kwargs, Serde, Value};
23use minijinja::{AutoEscape, Environment, ErrorKind, State, UndefinedBehavior};
24use serde::Serialize;
25use std::collections::HashMap;
26use std::fmt;
27use std::sync::{Arc, RwLock};
28
29/// The instruction budget a render may spend, so a runaway loop ends as an
30/// error rather than a hung request.
31const FUEL: u64 = 5_000_000;
32
33/// The environment name of a template: the kind's folder and the path.
34pub fn template_name(kind: Kind, path: &str) -> String {
35    format!("{}/{path}", folder(kind))
36}
37
38fn folder(kind: Kind) -> &'static str {
39    match kind {
40        Kind::Layout => "layouts",
41        Kind::Page => "pages",
42        Kind::Partial => "partials",
43        Kind::Content => "content",
44        Kind::Asset => "assets",
45    }
46}
47
48/// What went wrong rendering or parsing, with the template and line when known.
49#[derive(Debug, Clone)]
50pub struct RenderError {
51    /// The environment name of the template the error is in.
52    pub template: Option<String>,
53    /// The line within it, from one.
54    pub line: Option<usize>,
55    /// The engine's error class, as text (`syntax error`, `undefined value`).
56    pub kind: String,
57    pub message: String,
58}
59
60impl fmt::Display for RenderError {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match (&self.template, self.line) {
63            (Some(t), Some(l)) => write!(f, "{t}:{l}: {}", self.message),
64            (Some(t), None) => write!(f, "{t}: {}", self.message),
65            _ => f.write_str(&self.message),
66        }
67    }
68}
69
70impl std::error::Error for RenderError {}
71
72impl From<minijinja::Error> for RenderError {
73    fn from(e: minijinja::Error) -> Self {
74        let message = match e.detail() {
75            Some(detail) => format!("{}: {detail}", e.kind()),
76            None => e.kind().to_string(),
77        };
78        RenderError {
79            template: e.name().map(str::to_string),
80            line: e.line(),
81            kind: e.kind().to_string(),
82            message,
83        }
84    }
85}
86
87/// A theme's templates, compiled and ready to render.
88pub struct Engine {
89    env: Environment<'static>,
90    theme: Theme,
91    stamp: String,
92    names: Vec<String>,
93    routes: Arc<RouteTable>,
94    pages: HashMap<String, PageSettings>,
95    /// Each page template's compiled-to-author line map.
96    line_maps: HashMap<String, Vec<usize>>,
97    /// Each page name's stored path (`home` to `home.htm`).
98    page_paths: HashMap<String, String>,
99    /// Each layout's settings bag, by path.
100    layout_settings: HashMap<String, serde_json::Value>,
101    /// Each page's whole settings bag, by name.
102    page_bags: HashMap<String, serde_json::Value>,
103}
104
105/// A page's source as the environment compiles it, with a map from each
106/// compiled line to the author's.
107///
108/// A page that names a layout and does not extend a template itself is
109/// wrapped: `{% extends %}` and `{% block page %}` open on its first line, so
110/// the body keeps its line numbers. A block the page defines at its top level
111/// (`{% block title %}`) is lifted out of the page block to the end, blank
112/// lines holding its place, so it fills the layout's block without rendering
113/// where it stood; the map carries its lines back.
114fn compose_page(source: &str, layout: Option<&str>) -> (String, Vec<usize>) {
115    let lines = source.lines().count().max(1);
116    let identity: Vec<usize> = (1..=lines).collect();
117    let Some(layout) = layout else {
118        return (source.to_string(), identity);
119    };
120    if source.contains("{% extends") || source.contains("{%- extends") {
121        return (source.to_string(), identity);
122    }
123    let tag = regex::Regex::new(
124        r"\{%-?\s*(block\s+[A-Za-z_][A-Za-z0-9_]*|endblock(?:\s+[A-Za-z_][A-Za-z0-9_]*)?)\s*-?%\}",
125    )
126    .expect("a fixed expression");
127    let mut spans: Vec<std::ops::Range<usize>> = Vec::new();
128    let mut depth = 0usize;
129    let mut open = None;
130    for m in tag.find_iter(source) {
131        if m.as_str().contains("endblock") {
132            depth = depth.saturating_sub(1);
133            if depth == 0 {
134                if let Some(start) = open.take() {
135                    spans.push(start..m.end());
136                }
137            }
138        } else {
139            if depth == 0 {
140                open = Some(m.start());
141            }
142            depth += 1;
143        }
144    }
145    let mut body = String::with_capacity(source.len());
146    let mut hoisted: Vec<(usize, &str)> = Vec::new();
147    let mut at = 0;
148    for span in &spans {
149        body.push_str(&source[at..span.start]);
150        let text = &source[span.clone()];
151        body.extend(std::iter::repeat_n('\n', text.matches('\n').count()));
152        hoisted.push((source[..span.start].matches('\n').count() + 1, text));
153        at = span.end;
154    }
155    body.push_str(&source[at..]);
156    let mut compiled = format!(
157        "{{% extends {:?} %}}{{% block page %}}{body}{{% endblock %}}",
158        template_name(Kind::Layout, layout)
159    );
160    let mut map: Vec<usize> = (1..=body.lines().count().max(1)).collect();
161    for (origin, text) in hoisted {
162        compiled.push('\n');
163        compiled.push_str(text);
164        map.extend(text.lines().enumerate().map(|(i, _)| origin + i));
165    }
166    (compiled, map)
167}
168
169/// The theme as templates see it: its settings values, then `code`, `name`,
170/// `author`, `homepage` and `description`, which a setting cannot shadow.
171fn theme_value(theme: &Theme) -> serde_json::Value {
172    let mut map = match &theme.settings {
173        serde_json::Value::Object(values) => values.clone(),
174        _ => serde_json::Map::new(),
175    };
176    map.insert("code".into(), theme.code.clone().into());
177    map.insert("name".into(), theme.config.name.clone().into());
178    map.insert("author".into(), theme.config.author.clone().into());
179    map.insert("homepage".into(), theme.config.homepage.clone().into());
180    map.insert(
181        "description".into(),
182        theme.config.description.clone().into(),
183    );
184    serde_json::Value::Object(map)
185}
186
187/// The stamp of a chain: every theme's `updated_at`, nearest first. A change
188/// to the theme or any ancestor changes it.
189fn stamp_of(chain: &[Theme]) -> String {
190    chain
191        .iter()
192        .map(|t| format!("{}@{}", t.code, t.updated_at))
193        .collect::<Vec<_>>()
194        .join(";")
195}
196
197impl Engine {
198    /// Compiles the theme `code` with its ancestors.
199    pub async fn build(db: &Db, code: &str) -> Result<Engine, ThemeError> {
200        Engine::build_with(db, code, |_| {}).await
201    }
202
203    /// Like [`Engine::build`], with a hook that adds the application's own
204    /// functions, filters and globals to the environment before the templates
205    /// compile.
206    pub async fn build_with(
207        db: &Db,
208        code: &str,
209        configure: impl FnOnce(&mut Environment<'static>),
210    ) -> Result<Engine, ThemeError> {
211        let chain = store::chain(db, code).await?;
212        let stamp = stamp_of(&chain);
213        let mut sources: HashMap<String, String> = HashMap::new();
214        let mut content: HashMap<String, String> = HashMap::new();
215        let mut page_rows: HashMap<String, (serde_json::Value, String)> = HashMap::new();
216        let mut layout_settings: HashMap<String, serde_json::Value> = HashMap::new();
217        // Ancestors first, so a nearer theme's row replaces a farther one's.
218        for theme in chain.iter().rev() {
219            for t in store::list_templates(db, theme.id, None).await? {
220                match t.kind {
221                    Kind::Content => {
222                        content.insert(t.path, t.markup);
223                    }
224                    Kind::Page => {
225                        page_rows.insert(t.path, (t.settings, t.markup));
226                    }
227                    Kind::Layout => {
228                        layout_settings.insert(t.path.clone(), t.settings);
229                        sources.insert(template_name(t.kind, &t.path), t.markup);
230                    }
231                    _ => {
232                        sources.insert(template_name(t.kind, &t.path), t.markup);
233                    }
234                }
235            }
236        }
237        // A page with settings that do not parse is left out of the routes
238        // but still compiles, so the editor can open it.
239        let mut routes = RouteTable::default();
240        let mut pages: HashMap<String, PageSettings> = HashMap::new();
241        let mut line_maps: HashMap<String, Vec<usize>> = HashMap::new();
242        let mut page_paths: HashMap<String, String> = HashMap::new();
243        let mut page_bags: HashMap<String, serde_json::Value> = HashMap::new();
244        for (path, (settings, markup)) in page_rows {
245            let name = page_name(&path).to_string();
246            page_paths.insert(name.clone(), path.clone());
247            page_bags.insert(name.clone(), settings.clone());
248            let parsed = PageSettings::from_value(&settings).unwrap_or_default();
249            if let Ok(pattern) = parsed.pattern() {
250                routes.add(name.clone(), pattern);
251            }
252            let layout = parsed.layout_path();
253            let (compiled, map) = compose_page(&markup, layout.as_deref());
254            let template = template_name(Kind::Page, &path);
255            sources.insert(template.clone(), compiled);
256            line_maps.insert(template, map);
257            pages.insert(name, parsed);
258        }
259        let routes = Arc::new(routes);
260        let theme = chain
261            .into_iter()
262            .next()
263            .expect("a chain starts with its theme");
264        let mut env = base_environment();
265        env.add_global("theme", Value::from(Serde(theme_value(&theme))));
266        let content = Arc::new(content);
267        env.add_function(
268            "content",
269            move |name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
270                let Some(body) = content.get(&name) else {
271                    return Err(minijinja::Error::new(
272                        ErrorKind::TemplateNotFound,
273                        format!("no content block {name:?}"),
274                    ));
275                };
276                // `{name}` in the block is the named variable, escaped.
277                let mut text = body.clone();
278                for key in kwargs.args() {
279                    let value: Value = kwargs.get(key)?;
280                    text = text.replace(
281                        &format!("{{{key}}}"),
282                        &minijinja::HtmlEscape(&value.to_string()).to_string(),
283                    );
284                }
285                kwargs.assert_all_used()?;
286                Ok(Value::from_safe_string(render_content(&name, &text)))
287            },
288        );
289        env.add_function("partial", partial);
290        env.add_function("component", component);
291        let code = theme.code.clone();
292        env.add_function("asset", move |path: String| -> Value {
293            Value::from_safe_string(format!(
294                "/themes/{code}/assets/{}",
295                path.trim_start_matches('/')
296            ))
297        });
298        env.add_function("media", media);
299        env.add_filter("variant", |url: String, preset: String| -> Value {
300            Value::from_safe_string(variant(url, preset))
301        });
302        let table = Arc::clone(&routes);
303        env.add_function(
304            "page",
305            move |name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
306                let mut params = Params::new();
307                for key in kwargs.args() {
308                    params.insert(key.to_string(), kwargs.get::<String>(key)?);
309                }
310                kwargs.assert_all_used()?;
311                table
312                    .url(&name, &params)
313                    .map(Value::from_safe_string)
314                    .ok_or_else(|| {
315                        minijinja::Error::new(
316                            ErrorKind::InvalidOperation,
317                            format!("no page {name:?}"),
318                        )
319                    })
320            },
321        );
322        configure(&mut env);
323        let mut names: Vec<String> = sources.keys().cloned().collect();
324        names.sort();
325        for (name, source) in sources {
326            env.add_template_owned(name, source)
327                .map_err(|e| ThemeError::Template(RenderError::from(e)))?;
328        }
329        Ok(Engine {
330            env,
331            theme,
332            stamp,
333            names,
334            routes,
335            pages,
336            line_maps,
337            page_paths,
338            layout_settings,
339            page_bags,
340        })
341    }
342
343    /// The theme this engine renders.
344    pub fn theme(&self) -> &Theme {
345        &self.theme
346    }
347
348    /// Changes when the theme or an ancestor does; [`EngineCache`] compares it.
349    pub fn stamp(&self) -> &str {
350        &self.stamp
351    }
352
353    /// The environment, for a render path that needs more than [`Engine::render`].
354    pub fn env(&self) -> &Environment<'static> {
355        &self.env
356    }
357
358    /// Every compiled template's name, sorted.
359    pub fn names(&self) -> &[String] {
360        &self.names
361    }
362
363    /// The page a request path resolves to, with its parameters.
364    pub fn resolve(&self, path: &str) -> Option<(&str, Params)> {
365        self.routes.resolve(path)
366    }
367
368    /// A page's settings by name (its path without the extension).
369    pub fn page(&self, name: &str) -> Option<&PageSettings> {
370        self.pages.get(name)
371    }
372
373    /// A page's stored path by name.
374    pub fn page_path(&self, name: &str) -> Option<&str> {
375        self.page_paths.get(name).map(String::as_str)
376    }
377
378    /// A layout's settings bag by path.
379    pub fn layout_settings(&self, path: &str) -> Option<&serde_json::Value> {
380        self.layout_settings.get(path)
381    }
382
383    /// A page's whole settings bag by name, `components` included.
384    pub fn page_bag(&self, name: &str) -> Option<&serde_json::Value> {
385        self.page_bags.get(name)
386    }
387
388    /// The URL of the page `name` with `params`.
389    pub fn url_for(&self, name: &str, params: &Params) -> Option<String> {
390        self.routes.url(name, params)
391    }
392
393    /// The route table: every page's pattern in matching order.
394    pub fn routes(&self) -> &RouteTable {
395        &self.routes
396    }
397
398    /// Whether the theme, or an ancestor, has this template.
399    pub fn has(&self, kind: Kind, path: &str) -> bool {
400        self.names.binary_search(&template_name(kind, path)).is_ok()
401    }
402
403    /// Renders a template with `ctx` as its variables.
404    pub fn render(
405        &self,
406        kind: Kind,
407        path: &str,
408        ctx: impl Serialize,
409    ) -> Result<String, RenderError> {
410        let name = template_name(kind, path);
411        let template = self.env.get_template(&name)?;
412        template
413            .render(Value::from(Serde(ctx)))
414            .map_err(|e| self.author_line(RenderError::from(e)))
415    }
416
417    /// An error's line as the author sees it, for a page the engine composed.
418    pub fn author_line(&self, mut err: RenderError) -> RenderError {
419        if let (Some(template), Some(line)) = (&err.template, err.line) {
420            if let Some(map) = self.line_maps.get(template) {
421                if let Some(original) = map.get(line.saturating_sub(1)) {
422                    err.line = Some(*original);
423                }
424            }
425        }
426        err
427    }
428}
429
430/// The environment every theme compiles into: HTML escaped outside assets,
431/// a printed undefined value an error, a fuel budget.
432fn base_environment() -> Environment<'static> {
433    let mut env = Environment::new();
434    env.set_auto_escape_callback(|name| {
435        if name.starts_with("assets/") {
436            AutoEscape::None
437        } else {
438            AutoEscape::Html
439        }
440    });
441    env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
442    env.set_fuel(Some(FUEL));
443    env
444}
445
446/// `media(file)` or `media(file, preset="card")`: the URL of a media value
447/// (a URL string, or an object with `url` as [`super::media_value`] builds),
448/// under a preset when one is named. An empty value renders empty.
449fn media(value: Value, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
450    let preset: Option<String> = kwargs.get("preset")?;
451    kwargs.assert_all_used()?;
452    let url = media_url(&value);
453    Ok(match (url, preset) {
454        (Some(url), Some(p)) => Value::from_safe_string(variant(url, p)),
455        (Some(url), None) => Value::from_safe_string(url),
456        (None, _) => Value::from(""),
457    })
458}
459
460fn media_url(value: &Value) -> Option<String> {
461    if value.is_undefined() || value.is_none() {
462        return None;
463    }
464    if let Some(s) = value.as_str() {
465        return (!s.is_empty()).then(|| s.to_string());
466    }
467    value
468        .get_attr("url")
469        .ok()
470        .filter(|u| !u.is_undefined())
471        .and_then(|u| u.as_str().map(str::to_string))
472        .filter(|u| !u.is_empty())
473}
474
475/// `url|variant("card")`: a media URL rewritten to its variant in the preset,
476/// replacing one already named. A URL that is not a media URL is returned as
477/// it is.
478pub fn variant(url: String, preset: String) -> String {
479    const MARK: &str = "/storage/media/";
480    let Some(at) = url.find(MARK) else {
481        return url;
482    };
483    let (origin, rest) = url.split_at(at + MARK.len());
484    let mut parts = rest.splitn(3, '/');
485    let (Some(hash), Some(second)) = (parts.next(), parts.next()) else {
486        return url;
487    };
488    let name = match parts.next() {
489        // Already `<hash>/<preset>/<name>`: the name is the last part.
490        Some(name) => name,
491        None => second,
492    };
493    format!("{origin}{hash}/{preset}/{name}")
494}
495
496/// `partial("nav.htm", title="Acme")`: renders `partials/nav.htm` with the
497/// named variables, the request's `this` and `head`, and the globals.
498fn partial(state: &State, name: String, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
499    let template = state
500        .env()
501        .get_template(&template_name(Kind::Partial, &name))?;
502    let mut pairs: Vec<(String, Value)> = ["this", "head"]
503        .into_iter()
504        .filter_map(|key| state.lookup(key).map(|v| (key.to_string(), v)))
505        .collect();
506    for key in kwargs.args() {
507        pairs.push((key.to_string(), kwargs.get::<Value>(key)?));
508    }
509    kwargs.assert_all_used()?;
510    Ok(Value::from_safe_string(
511        template.render(Value::from_pairs(pairs))?,
512    ))
513}
514
515/// `component("post")`: renders the theme's `partials/post/default.htm`, else
516/// the component's default markup, with the component's data as `component`
517/// and under its alias, beside the request's `this` and `head`.
518fn component(state: &State, alias: String) -> Result<Value, minijinja::Error> {
519    let code = state
520        .lookup(super::component::ALIASES_KEY)
521        .and_then(|m| m.get_attr(&alias).ok())
522        .filter(|v| !v.is_undefined())
523        .map(|v| v.to_string())
524        .ok_or_else(|| {
525            minijinja::Error::new(
526                ErrorKind::InvalidOperation,
527                format!("no component is attached as {alias:?}"),
528            )
529        })?;
530    let own = template_name(Kind::Partial, &format!("{alias}/default.htm"));
531    let template = match state.env().get_template(&own) {
532        Ok(t) => t,
533        Err(_) => state
534            .env()
535            .get_template(&super::component::template_name(&code))
536            .map_err(|_| {
537                minijinja::Error::new(
538                    ErrorKind::TemplateNotFound,
539                    format!("no markup for the component {code:?} as {alias:?}: add partials/{alias}/default.htm"),
540                )
541            })?,
542    };
543    let data = state.lookup(&alias).unwrap_or_default();
544    let mut pairs: Vec<(String, Value)> = ["this", "head"]
545        .into_iter()
546        .filter_map(|key| state.lookup(key).map(|v| (key.to_string(), v)))
547        .collect();
548    pairs.push((alias.clone(), data.clone()));
549    pairs.push(("component".to_string(), data));
550    Ok(Value::from_safe_string(
551        template.render(Value::from_pairs(pairs))?,
552    ))
553}
554
555/// A content block as HTML: Markdown rendered (`.md`), HTML as it is (`.htm`,
556/// `.html`), anything else escaped as text.
557pub fn render_content(path: &str, body: &str) -> String {
558    let ext = path.rsplit('.').next().unwrap_or_default();
559    match ext {
560        "md" | "markdown" => {
561            let mut options = pulldown_cmark::Options::empty();
562            options.insert(pulldown_cmark::Options::ENABLE_TABLES);
563            options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
564            options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
565            let parser = pulldown_cmark::Parser::new_ext(body, options);
566            let mut html = String::with_capacity(body.len() * 2);
567            pulldown_cmark::html::push_html(&mut html, parser);
568            html
569        }
570        "htm" | "html" => body.to_string(),
571        _ => minijinja::HtmlEscape(body).to_string(),
572    }
573}
574
575/// Parses a template's markup without rendering it. The error carries the
576/// line, so an editor can point at it.
577pub fn check(source: &str) -> Result<(), RenderError> {
578    let env = base_environment();
579    env.template_from_str(source)?;
580    Ok(())
581}
582
583/// One engine per theme, rebuilt when the theme or an ancestor changes.
584#[derive(Default)]
585pub struct EngineCache {
586    engines: RwLock<HashMap<String, Arc<Engine>>>,
587}
588
589impl EngineCache {
590    pub fn new() -> Self {
591        Self::default()
592    }
593
594    /// The engine for `code`, built or rebuilt as its stamp requires.
595    pub async fn get(&self, db: &Db, code: &str) -> Result<Arc<Engine>, ThemeError> {
596        self.get_with(db, code, |_| {}).await
597    }
598
599    /// Like [`EngineCache::get`], configuring a rebuilt engine as
600    /// [`Engine::build_with`] does.
601    pub async fn get_with(
602        &self,
603        db: &Db,
604        code: &str,
605        configure: impl FnOnce(&mut Environment<'static>),
606    ) -> Result<Arc<Engine>, ThemeError> {
607        let chain = store::chain(db, code).await?;
608        let stamp = stamp_of(&chain);
609        if let Some(engine) = self.engines.read().expect("engine cache").get(code) {
610            if engine.stamp == stamp {
611                return Ok(Arc::clone(engine));
612            }
613        }
614        let engine = Arc::new(Engine::build_with(db, code, configure).await?);
615        self.engines
616            .write()
617            .expect("engine cache")
618            .insert(code.to_string(), Arc::clone(&engine));
619        Ok(engine)
620    }
621
622    /// Drops the engine for `code`, so the next request rebuilds it.
623    pub fn invalidate(&self, code: &str) {
624        self.engines.write().expect("engine cache").remove(code);
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn a_page_is_composed_into_its_layout_with_its_blocks_lifted() {
634        let (compiled, map) = compose_page(
635            "a\n{% block title %}T\nU{% endblock %}\nb {{ x }}",
636            Some("default.htm"),
637        );
638        assert_eq!(
639            compiled,
640            "{% extends \"layouts/default.htm\" %}{% block page %}a\n\n\nb {{ x }}{% endblock %}\n{% block title %}T\nU{% endblock %}"
641        );
642        // Body lines keep their numbers; the lifted block maps back to 2 and 3.
643        assert_eq!(map, vec![1, 2, 3, 4, 2, 3]);
644
645        let (plain, map) = compose_page("x\ny", None);
646        assert_eq!(plain, "x\ny");
647        assert_eq!(map, vec![1, 2]);
648        let (own, _) = compose_page("{% extends \"layouts/a.htm\" %}", Some("default.htm"));
649        assert!(own.starts_with("{% extends \"layouts/a.htm\" %}"));
650        // An unmatched block is left where it is.
651        let (unmatched, _) = compose_page("{% block t %}x", Some("default.htm"));
652        assert!(unmatched.contains("{% block page %}{% block t %}x{% endblock %}"));
653    }
654}