laterite-web 0.9.16

Laterite CMF public web layer: themes, static-site generation, page metadata, canonical URLs, and sitemaps
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
//! Rendering: a theme's templates compiled into one environment.
//!
//! An [`Engine`] holds a theme's layouts, pages, partials and assets, with
//! every ancestor's filling in for the ones the theme lacks, as named
//! templates: `layouts/default.htm`, `pages/home.htm`, `partials/nav.htm`,
//! `assets/css/site.css`. A page extends a layout with `{% extends
//! "layouts/default.htm" %}` and fills its blocks; a template includes a
//! partial with `{% include "partials/nav.htm" %}` or renders one with its own
//! variables through `partial("nav.htm", title="Acme")`; `content("intro.md")`
//! places a content block, Markdown rendered to HTML. HTML is escaped
//! everywhere but inside an asset. A printed undefined value is an error; a
//! tested one is not.
//!
//! [`check`] parses a template without rendering it, so a save can refuse a
//! template that would fail. An [`EngineCache`] keeps one engine per theme and
//! rebuilds it when the theme or an ancestor changes.

use super::page::{page_name, PageSettings};
use super::router::{Params, RouteTable};
use super::{store, Kind, Theme, ThemeError};
use laterite_core::Db;
use minijinja::value::{Kwargs, Serde, Value};
use minijinja::{AutoEscape, Environment, ErrorKind, State, UndefinedBehavior};
use serde::Serialize;
use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, RwLock};

/// The instruction budget a render may spend, so a runaway loop ends as an
/// error rather than a hung request.
const FUEL: u64 = 5_000_000;

/// The environment name of a template: the kind's folder and the path.
pub fn template_name(kind: Kind, path: &str) -> String {
    format!("{}/{path}", folder(kind))
}

fn folder(kind: Kind) -> &'static str {
    match kind {
        Kind::Layout => "layouts",
        Kind::Page => "pages",
        Kind::Partial => "partials",
        Kind::Content => "content",
        Kind::Asset => "assets",
    }
}

/// What went wrong rendering or parsing, with the template and line when known.
#[derive(Debug, Clone)]
pub struct RenderError {
    /// The environment name of the template the error is in.
    pub template: Option<String>,
    /// The line within it, from one.
    pub line: Option<usize>,
    /// The engine's error class, as text (`syntax error`, `undefined value`).
    pub kind: String,
    pub message: String,
}

impl fmt::Display for RenderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match (&self.template, self.line) {
            (Some(t), Some(l)) => write!(f, "{t}:{l}: {}", self.message),
            (Some(t), None) => write!(f, "{t}: {}", self.message),
            _ => f.write_str(&self.message),
        }
    }
}

impl std::error::Error for RenderError {}

impl From<minijinja::Error> for RenderError {
    fn from(e: minijinja::Error) -> Self {
        let message = match e.detail() {
            Some(detail) => format!("{}: {detail}", e.kind()),
            None => e.kind().to_string(),
        };
        RenderError {
            template: e.name().map(str::to_string),
            line: e.line(),
            kind: e.kind().to_string(),
            message,
        }
    }
}

/// A theme's templates, compiled and ready to render.
pub struct Engine {
    env: Environment<'static>,
    theme: Theme,
    stamp: String,
    names: Vec<String>,
    routes: Arc<RouteTable>,
    pages: HashMap<String, PageSettings>,
    /// Each page template's compiled-to-author line map.
    line_maps: HashMap<String, Vec<usize>>,
    /// Each page name's stored path (`home` to `home.htm`).
    page_paths: HashMap<String, String>,
    /// Each layout's settings bag, by path.
    layout_settings: HashMap<String, serde_json::Value>,
    /// Each page's whole settings bag, by name.
    page_bags: HashMap<String, serde_json::Value>,
}

/// A page's source as the environment compiles it, with a map from each
/// compiled line to the author's.
///
/// A page that names a layout and does not extend a template itself is
/// wrapped: `{% extends %}` and `{% block page %}` open on its first line, so
/// the body keeps its line numbers. A block the page defines at its top level
/// (`{% block title %}`) is lifted out of the page block to the end, blank
/// lines holding its place, so it fills the layout's block without rendering
/// where it stood; the map carries its lines back.
fn compose_page(source: &str, layout: Option<&str>) -> (String, Vec<usize>) {
    let lines = source.lines().count().max(1);
    let identity: Vec<usize> = (1..=lines).collect();
    let Some(layout) = layout else {
        return (source.to_string(), identity);
    };
    if source.contains("{% extends") || source.contains("{%- extends") {
        return (source.to_string(), identity);
    }
    let tag = regex::Regex::new(
        r"\{%-?\s*(block\s+[A-Za-z_][A-Za-z0-9_]*|endblock(?:\s+[A-Za-z_][A-Za-z0-9_]*)?)\s*-?%\}",
    )
    .expect("a fixed expression");
    let mut spans: Vec<std::ops::Range<usize>> = Vec::new();
    let mut depth = 0usize;
    let mut open = None;
    for m in tag.find_iter(source) {
        if m.as_str().contains("endblock") {
            depth = depth.saturating_sub(1);
            if depth == 0 {
                if let Some(start) = open.take() {
                    spans.push(start..m.end());
                }
            }
        } else {
            if depth == 0 {
                open = Some(m.start());
            }
            depth += 1;
        }
    }
    let mut body = String::with_capacity(source.len());
    let mut hoisted: Vec<(usize, &str)> = Vec::new();
    let mut at = 0;
    for span in &spans {
        body.push_str(&source[at..span.start]);
        let text = &source[span.clone()];
        body.extend(std::iter::repeat_n('\n', text.matches('\n').count()));
        hoisted.push((source[..span.start].matches('\n').count() + 1, text));
        at = span.end;
    }
    body.push_str(&source[at..]);
    let mut compiled = format!(
        "{{% extends {:?} %}}{{% block page %}}{body}{{% endblock %}}",
        template_name(Kind::Layout, layout)
    );
    let mut map: Vec<usize> = (1..=body.lines().count().max(1)).collect();
    for (origin, text) in hoisted {
        compiled.push('\n');
        compiled.push_str(text);
        map.extend(text.lines().enumerate().map(|(i, _)| origin + i));
    }
    (compiled, map)
}

/// The theme as templates see it: its settings values, then `code`, `name`,
/// `author`, `homepage` and `description`, which a setting cannot shadow.
fn theme_value(theme: &Theme) -> serde_json::Value {
    let mut map = match &theme.settings {
        serde_json::Value::Object(values) => values.clone(),
        _ => serde_json::Map::new(),
    };
    map.insert("code".into(), theme.code.clone().into());
    map.insert("name".into(), theme.config.name.clone().into());
    map.insert("author".into(), theme.config.author.clone().into());
    map.insert("homepage".into(), theme.config.homepage.clone().into());
    map.insert(
        "description".into(),
        theme.config.description.clone().into(),
    );
    serde_json::Value::Object(map)
}

/// The stamp of a chain: every theme's `updated_at`, nearest first. A change
/// to the theme or any ancestor changes it.
fn stamp_of(chain: &[Theme]) -> String {
    chain
        .iter()
        .map(|t| format!("{}@{}", t.code, t.updated_at))
        .collect::<Vec<_>>()
        .join(";")
}

impl Engine {
    /// Compiles the theme `code` with its ancestors.
    pub async fn build(db: &Db, code: &str) -> Result<Engine, ThemeError> {
        Engine::build_with(db, code, |_| {}).await
    }

    /// Like [`Engine::build`], with a hook that adds the application's own
    /// functions, filters and globals to the environment before the templates
    /// compile.
    pub async fn build_with(
        db: &Db,
        code: &str,
        configure: impl FnOnce(&mut Environment<'static>),
    ) -> Result<Engine, ThemeError> {
        let chain = store::chain(db, code).await?;
        let stamp = stamp_of(&chain);
        let mut sources: HashMap<String, String> = HashMap::new();
        let mut content: HashMap<String, String> = HashMap::new();
        let mut page_rows: HashMap<String, (serde_json::Value, String)> = HashMap::new();
        let mut layout_settings: HashMap<String, serde_json::Value> = HashMap::new();
        // Ancestors first, so a nearer theme's row replaces a farther one's.
        for theme in chain.iter().rev() {
            for t in store::list_templates(db, theme.id, None).await? {
                match t.kind {
                    Kind::Content => {
                        content.insert(t.path, t.markup);
                    }
                    Kind::Page => {
                        page_rows.insert(t.path, (t.settings, t.markup));
                    }
                    Kind::Layout => {
                        layout_settings.insert(t.path.clone(), t.settings);
                        sources.insert(template_name(t.kind, &t.path), t.markup);
                    }
                    _ => {
                        sources.insert(template_name(t.kind, &t.path), t.markup);
                    }
                }
            }
        }
        // A page with settings that do not parse is left out of the routes
        // but still compiles, so the editor can open it.
        let mut routes = RouteTable::default();
        let mut pages: HashMap<String, PageSettings> = HashMap::new();
        let mut line_maps: HashMap<String, Vec<usize>> = HashMap::new();
        let mut page_paths: HashMap<String, String> = HashMap::new();
        let mut page_bags: HashMap<String, serde_json::Value> = HashMap::new();
        for (path, (settings, markup)) in page_rows {
            let name = page_name(&path).to_string();
            page_paths.insert(name.clone(), path.clone());
            page_bags.insert(name.clone(), settings.clone());
            let parsed = PageSettings::from_value(&settings).unwrap_or_default();
            if let Ok(pattern) = parsed.pattern() {
                routes.add(name.clone(), pattern);
            }
            let layout = parsed.layout_path();
            let (compiled, map) = compose_page(&markup, layout.as_deref());
            let template = template_name(Kind::Page, &path);
            sources.insert(template.clone(), compiled);
            line_maps.insert(template, map);
            pages.insert(name, parsed);
        }
        let routes = Arc::new(routes);
        let theme = chain
            .into_iter()
            .next()
            .expect("a chain starts with its theme");
        let mut env = base_environment();
        env.add_global("theme", Value::from(Serde(theme_value(&theme))));
        let content = Arc::new(content);
        env.add_function(
            "content",
            move |name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
                let Some(body) = content.get(&name) else {
                    return Err(minijinja::Error::new(
                        ErrorKind::TemplateNotFound,
                        format!("no content block {name:?}"),
                    ));
                };
                // `{name}` in the block is the named variable, escaped.
                let mut text = body.clone();
                for key in kwargs.args() {
                    let value: Value = kwargs.get(key)?;
                    text = text.replace(
                        &format!("{{{key}}}"),
                        &minijinja::HtmlEscape(&value.to_string()).to_string(),
                    );
                }
                kwargs.assert_all_used()?;
                Ok(Value::from_safe_string(render_content(&name, &text)))
            },
        );
        env.add_function("partial", partial);
        env.add_function("component", component);
        let code = theme.code.clone();
        env.add_function("asset", move |path: String| -> Value {
            Value::from_safe_string(format!(
                "/themes/{code}/assets/{}",
                path.trim_start_matches('/')
            ))
        });
        env.add_function("media", media);
        env.add_filter("variant", |url: String, preset: String| -> Value {
            Value::from_safe_string(variant(url, preset))
        });
        let table = Arc::clone(&routes);
        env.add_function(
            "page",
            move |name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
                let mut params = Params::new();
                for key in kwargs.args() {
                    params.insert(key.to_string(), kwargs.get::<String>(key)?);
                }
                kwargs.assert_all_used()?;
                table
                    .url(&name, &params)
                    .map(Value::from_safe_string)
                    .ok_or_else(|| {
                        minijinja::Error::new(
                            ErrorKind::InvalidOperation,
                            format!("no page {name:?}"),
                        )
                    })
            },
        );
        configure(&mut env);
        let mut names: Vec<String> = sources.keys().cloned().collect();
        names.sort();
        for (name, source) in sources {
            env.add_template_owned(name, source)
                .map_err(|e| ThemeError::Template(RenderError::from(e)))?;
        }
        Ok(Engine {
            env,
            theme,
            stamp,
            names,
            routes,
            pages,
            line_maps,
            page_paths,
            layout_settings,
            page_bags,
        })
    }

    /// The theme this engine renders.
    pub fn theme(&self) -> &Theme {
        &self.theme
    }

    /// Changes when the theme or an ancestor does; [`EngineCache`] compares it.
    pub fn stamp(&self) -> &str {
        &self.stamp
    }

    /// The environment, for a render path that needs more than [`Engine::render`].
    pub fn env(&self) -> &Environment<'static> {
        &self.env
    }

    /// Every compiled template's name, sorted.
    pub fn names(&self) -> &[String] {
        &self.names
    }

    /// The page a request path resolves to, with its parameters.
    pub fn resolve(&self, path: &str) -> Option<(&str, Params)> {
        self.routes.resolve(path)
    }

    /// A page's settings by name (its path without the extension).
    pub fn page(&self, name: &str) -> Option<&PageSettings> {
        self.pages.get(name)
    }

    /// A page's stored path by name.
    pub fn page_path(&self, name: &str) -> Option<&str> {
        self.page_paths.get(name).map(String::as_str)
    }

    /// A layout's settings bag by path.
    pub fn layout_settings(&self, path: &str) -> Option<&serde_json::Value> {
        self.layout_settings.get(path)
    }

    /// A page's whole settings bag by name, `components` included.
    pub fn page_bag(&self, name: &str) -> Option<&serde_json::Value> {
        self.page_bags.get(name)
    }

    /// The URL of the page `name` with `params`.
    pub fn url_for(&self, name: &str, params: &Params) -> Option<String> {
        self.routes.url(name, params)
    }

    /// The route table: every page's pattern in matching order.
    pub fn routes(&self) -> &RouteTable {
        &self.routes
    }

    /// Whether the theme, or an ancestor, has this template.
    pub fn has(&self, kind: Kind, path: &str) -> bool {
        self.names.binary_search(&template_name(kind, path)).is_ok()
    }

    /// Renders a template with `ctx` as its variables.
    pub fn render(
        &self,
        kind: Kind,
        path: &str,
        ctx: impl Serialize,
    ) -> Result<String, RenderError> {
        let name = template_name(kind, path);
        let template = self.env.get_template(&name)?;
        template
            .render(Value::from(Serde(ctx)))
            .map_err(|e| self.author_line(RenderError::from(e)))
    }

    /// An error's line as the author sees it, for a page the engine composed.
    pub fn author_line(&self, mut err: RenderError) -> RenderError {
        if let (Some(template), Some(line)) = (&err.template, err.line) {
            if let Some(map) = self.line_maps.get(template) {
                if let Some(original) = map.get(line.saturating_sub(1)) {
                    err.line = Some(*original);
                }
            }
        }
        err
    }
}

/// The environment every theme compiles into: HTML escaped outside assets,
/// a printed undefined value an error, a fuel budget.
fn base_environment() -> Environment<'static> {
    let mut env = Environment::new();
    env.set_auto_escape_callback(|name| {
        if name.starts_with("assets/") {
            AutoEscape::None
        } else {
            AutoEscape::Html
        }
    });
    env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
    env.set_fuel(Some(FUEL));
    env
}

/// `media(file)` or `media(file, preset="card")`: the URL of a media value
/// (a URL string, or an object with `url` as [`super::media_value`] builds),
/// under a preset when one is named. An empty value renders empty.
fn media(value: Value, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
    let preset: Option<String> = kwargs.get("preset")?;
    kwargs.assert_all_used()?;
    let url = media_url(&value);
    Ok(match (url, preset) {
        (Some(url), Some(p)) => Value::from_safe_string(variant(url, p)),
        (Some(url), None) => Value::from_safe_string(url),
        (None, _) => Value::from(""),
    })
}

fn media_url(value: &Value) -> Option<String> {
    if value.is_undefined() || value.is_none() {
        return None;
    }
    if let Some(s) = value.as_str() {
        return (!s.is_empty()).then(|| s.to_string());
    }
    value
        .get_attr("url")
        .ok()
        .filter(|u| !u.is_undefined())
        .and_then(|u| u.as_str().map(str::to_string))
        .filter(|u| !u.is_empty())
}

/// `url|variant("card")`: a media URL rewritten to its variant in the preset,
/// replacing one already named. A URL that is not a media URL is returned as
/// it is.
pub fn variant(url: String, preset: String) -> String {
    const MARK: &str = "/storage/media/";
    let Some(at) = url.find(MARK) else {
        return url;
    };
    let (origin, rest) = url.split_at(at + MARK.len());
    let mut parts = rest.splitn(3, '/');
    let (Some(hash), Some(second)) = (parts.next(), parts.next()) else {
        return url;
    };
    let name = match parts.next() {
        // Already `<hash>/<preset>/<name>`: the name is the last part.
        Some(name) => name,
        None => second,
    };
    format!("{origin}{hash}/{preset}/{name}")
}

/// `partial("nav.htm", title="Acme")`: renders `partials/nav.htm` with the
/// named variables, the request's `this` and `head`, and the globals.
fn partial(state: &State, name: String, kwargs: Kwargs) -> Result<Value, minijinja::Error> {
    let template = state
        .env()
        .get_template(&template_name(Kind::Partial, &name))?;
    let mut pairs: Vec<(String, Value)> = ["this", "head"]
        .into_iter()
        .filter_map(|key| state.lookup(key).map(|v| (key.to_string(), v)))
        .collect();
    for key in kwargs.args() {
        pairs.push((key.to_string(), kwargs.get::<Value>(key)?));
    }
    kwargs.assert_all_used()?;
    Ok(Value::from_safe_string(
        template.render(Value::from_pairs(pairs))?,
    ))
}

/// `component("post")`: renders the theme's `partials/post/default.htm`, else
/// the component's default markup, with the component's data as `component`
/// and under its alias, beside the request's `this` and `head`.
fn component(state: &State, alias: String) -> Result<Value, minijinja::Error> {
    let code = state
        .lookup(super::component::ALIASES_KEY)
        .and_then(|m| m.get_attr(&alias).ok())
        .filter(|v| !v.is_undefined())
        .map(|v| v.to_string())
        .ok_or_else(|| {
            minijinja::Error::new(
                ErrorKind::InvalidOperation,
                format!("no component is attached as {alias:?}"),
            )
        })?;
    let own = template_name(Kind::Partial, &format!("{alias}/default.htm"));
    let template = match state.env().get_template(&own) {
        Ok(t) => t,
        Err(_) => state
            .env()
            .get_template(&super::component::template_name(&code))
            .map_err(|_| {
                minijinja::Error::new(
                    ErrorKind::TemplateNotFound,
                    format!("no markup for the component {code:?} as {alias:?}: add partials/{alias}/default.htm"),
                )
            })?,
    };
    let data = state.lookup(&alias).unwrap_or_default();
    let mut pairs: Vec<(String, Value)> = ["this", "head"]
        .into_iter()
        .filter_map(|key| state.lookup(key).map(|v| (key.to_string(), v)))
        .collect();
    pairs.push((alias.clone(), data.clone()));
    pairs.push(("component".to_string(), data));
    Ok(Value::from_safe_string(
        template.render(Value::from_pairs(pairs))?,
    ))
}

/// A content block as HTML: Markdown rendered (`.md`), HTML as it is (`.htm`,
/// `.html`), anything else escaped as text.
pub fn render_content(path: &str, body: &str) -> String {
    let ext = path.rsplit('.').next().unwrap_or_default();
    match ext {
        "md" | "markdown" => {
            let mut options = pulldown_cmark::Options::empty();
            options.insert(pulldown_cmark::Options::ENABLE_TABLES);
            options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
            options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
            let parser = pulldown_cmark::Parser::new_ext(body, options);
            let mut html = String::with_capacity(body.len() * 2);
            pulldown_cmark::html::push_html(&mut html, parser);
            html
        }
        "htm" | "html" => body.to_string(),
        _ => minijinja::HtmlEscape(body).to_string(),
    }
}

/// Parses a template's markup without rendering it. The error carries the
/// line, so an editor can point at it.
pub fn check(source: &str) -> Result<(), RenderError> {
    let env = base_environment();
    env.template_from_str(source)?;
    Ok(())
}

/// One engine per theme, rebuilt when the theme or an ancestor changes.
#[derive(Default)]
pub struct EngineCache {
    engines: RwLock<HashMap<String, Arc<Engine>>>,
}

impl EngineCache {
    pub fn new() -> Self {
        Self::default()
    }

    /// The engine for `code`, built or rebuilt as its stamp requires.
    pub async fn get(&self, db: &Db, code: &str) -> Result<Arc<Engine>, ThemeError> {
        self.get_with(db, code, |_| {}).await
    }

    /// Like [`EngineCache::get`], configuring a rebuilt engine as
    /// [`Engine::build_with`] does.
    pub async fn get_with(
        &self,
        db: &Db,
        code: &str,
        configure: impl FnOnce(&mut Environment<'static>),
    ) -> Result<Arc<Engine>, ThemeError> {
        let chain = store::chain(db, code).await?;
        let stamp = stamp_of(&chain);
        if let Some(engine) = self.engines.read().expect("engine cache").get(code) {
            if engine.stamp == stamp {
                return Ok(Arc::clone(engine));
            }
        }
        let engine = Arc::new(Engine::build_with(db, code, configure).await?);
        self.engines
            .write()
            .expect("engine cache")
            .insert(code.to_string(), Arc::clone(&engine));
        Ok(engine)
    }

    /// Drops the engine for `code`, so the next request rebuilds it.
    pub fn invalidate(&self, code: &str) {
        self.engines.write().expect("engine cache").remove(code);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_page_is_composed_into_its_layout_with_its_blocks_lifted() {
        let (compiled, map) = compose_page(
            "a\n{% block title %}T\nU{% endblock %}\nb {{ x }}",
            Some("default.htm"),
        );
        assert_eq!(
            compiled,
            "{% extends \"layouts/default.htm\" %}{% block page %}a\n\n\nb {{ x }}{% endblock %}\n{% block title %}T\nU{% endblock %}"
        );
        // Body lines keep their numbers; the lifted block maps back to 2 and 3.
        assert_eq!(map, vec![1, 2, 3, 4, 2, 3]);

        let (plain, map) = compose_page("x\ny", None);
        assert_eq!(plain, "x\ny");
        assert_eq!(map, vec![1, 2]);
        let (own, _) = compose_page("{% extends \"layouts/a.htm\" %}", Some("default.htm"));
        assert!(own.starts_with("{% extends \"layouts/a.htm\" %}"));
        // An unmatched block is left where it is.
        let (unmatched, _) = compose_page("{% block t %}x", Some("default.htm"));
        assert!(unmatched.contains("{% block page %}{% block t %}x{% endblock %}"));
    }
}