Skip to main content

plates_render/
shell.rs

1//! The site shell as named slots, and the substitutor that fills a
2//! caller-supplied template with them.
3//!
4//! A shell template is the outer HTML document a page is wrapped in: everything
5//! from `<!DOCTYPE html>` down to `</html>`, with the parts this crate computes
6//! left as named slots. The built-in shell in [`crate::html`] fills exactly the
7//! same [`ShellSlots`], so a template is a replacement for that document rather
8//! than a second, parallel notion of what a page is made of.
9//!
10//! ## Why not handlebars
11//!
12//! The reason that decides it is that **handlebars-rust has no configurable
13//! delimiters** — `{{` is hardcoded in its grammar. A shell is an HTML
14//! document, which is exactly where inline `<style>` and `<script>` braces
15//! live, and the `braces_that_are_not_slots_pass_through` test below guarantees
16//! that `<style>a{b:c}</style>` and `<script>if(x){{y()}}</script>` survive a
17//! shell verbatim. Handlebars would read `{{y()}}` as an expression, breaking
18//! every existing theme in favour of `\{{`.
19//!
20//! Two further reasons used to be listed here and are recorded as *refuted*,
21//! since both are contradicted by this crate's own code: that handlebars
22//! escapes by its own rule (`register_escape_fn` installs one, so
23//! [`crate::page::html_escape`] could have been it), and that a misspelled
24//! variable cannot be reported precisely (`Template::elements` is `pub`, so
25//! walking the compiled AST to validate slot names is a short function).
26//!
27//! Bodies pay no delimiter cost, because a body is Markdown — which is why
28//! [`crate::template`] spells its values with a directive instead, and why this
29//! module keeps a substitutor of its own rather than sharing one. It is
30//! deliberately small: named slots, no expressions, no control flow. Anything a
31//! shell wants to vary per page it varies by rendering a different site.
32//!
33//! ## Syntax
34//!
35//! `{{name}}` inserts a **text** slot, HTML-escaped. `{{{name}}}` inserts a
36//! **raw HTML** slot verbatim. Whitespace inside the braces is allowed
37//! (`{{ site_title }}`). The two spellings are not interchangeable: each slot is
38//! one kind or the other, and writing it the other way is an error rather than a
39//! silently escaped `<div>`. Anything that is not a well-formed slot reference —
40//! `{{` in an inline script, a CSS block, a `{{}}` with no name — passes through
41//! literally.
42
43use crate::page::html_escape;
44
45/// The named values a shell template is filled with.
46///
47/// Text fields hold their *unescaped* text; escaping happens where the slot is
48/// filled, so a value is escaped exactly once no matter which shell renders it.
49#[derive(Debug, Clone, Default)]
50pub struct ShellSlots {
51    /// `{{lang}}` — the document language, for `<html lang="…">`.
52    pub lang: String,
53    /// `{{document_title}}` — the `<title>` text: `"Entry - Site"`, or just the
54    /// site's name on the page that *is* the site.
55    pub document_title: String,
56    /// `{{site_title}}` — the site's name on its own.
57    pub site_title: String,
58    /// `{{body_class}}` — the class list for `<body>`, to be written *inside*
59    /// `class="…"`. Empty when the page has no site nav.
60    pub body_class: String,
61    /// `{{root_prefix}}` — the path from this page back up to the site root:
62    /// `../` per level of depth, empty at the root. Every prefixed href in
63    /// `head` and `site_nav` was computed from it; a template writing its own
64    /// `<a href="{{root_prefix}}index.html">` or `<img src="{{root_prefix}}logo.svg">`
65    /// needs the same value.
66    pub root_prefix: String,
67    /// `{{{head}}}` — stylesheet link, favicon link, SEO meta, feed links and
68    /// the page's own `styles:`, as a newline-separated run of tags indented
69    /// four spaces. Does **not** include `<title>`, which is its own slot.
70    pub head: String,
71    /// `{{{site_nav}}}` — the site navigation: the mobile bar with its menu
72    /// button, then the sidebar with the masthead and the tree. Empty when
73    /// the site has no nav tree.
74    pub site_nav: String,
75    /// `{{{breadcrumbs}}}` — the breadcrumb trail for this page.
76    pub breadcrumbs: String,
77    /// `{{{toc}}}` — the page's outline, a `<nav class="toc">` of its `h2`–`h3`
78    /// headings. Empty when there are fewer than two, or the page said
79    /// `toc: false`.
80    pub toc: String,
81    /// `{{{site_header}}}` — the site's header document, rendered for this
82    /// page. Empty when the site declares none.
83    pub site_header: String,
84    /// `{{{content}}}` — the rendered body, with its links already rewritten.
85    pub content: String,
86    /// `{{{pager}}}` — `<nav class="pager">` linking the previous and next
87    /// page in the nav's reading order. Empty for a page the nav does not
88    /// hold, or a site of one page.
89    pub pager: String,
90    /// `{{{site_footer}}}` — the site's footer document, rendered for this
91    /// page. Empty when the site declares none.
92    pub site_footer: String,
93    /// `{{{footer}}}` — the built-in attribution footer.
94    pub footer: String,
95    /// `{{{scripts}}}` — the built-in interactivity script and the page's own
96    /// `scripts:`, as a newline-separated run of tags indented four spaces.
97    pub scripts: String,
98}
99
100/// Whether a slot is text (escaped on the way in) or raw HTML.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum Kind {
103    Text,
104    Raw,
105}
106
107/// Every slot a template may name, with its kind. The order is the order the
108/// error message lists them in.
109const SLOTS: &[(&str, Kind)] = &[
110    ("lang", Kind::Text),
111    ("document_title", Kind::Text),
112    ("site_title", Kind::Text),
113    ("body_class", Kind::Text),
114    ("root_prefix", Kind::Text),
115    ("head", Kind::Raw),
116    ("site_nav", Kind::Raw),
117    ("breadcrumbs", Kind::Raw),
118    ("toc", Kind::Raw),
119    ("site_header", Kind::Raw),
120    ("content", Kind::Raw),
121    ("pager", Kind::Raw),
122    ("site_footer", Kind::Raw),
123    ("footer", Kind::Raw),
124    ("scripts", Kind::Raw),
125];
126
127/// A shell template that could not be compiled. Carries a message written for
128/// whoever wrote the template, since that is the only person who can fix it.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ShellError(String);
131
132impl ShellError {
133    /// The message, for a caller that reports it its own way.
134    pub fn message(&self) -> &str {
135        &self.0
136    }
137}
138
139impl std::fmt::Display for ShellError {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.write_str(&self.0)
142    }
143}
144
145impl std::error::Error for ShellError {}
146
147/// One piece of a compiled template.
148#[derive(Debug)]
149enum Segment {
150    Literal(String),
151    /// An index into [`SLOTS`], resolved at compile time so rendering is a
152    /// lookup rather than a second parse.
153    Slot(usize),
154}
155
156/// A compiled shell template.
157///
158/// Compiling is separate from rendering so a bad template is reported once,
159/// against the file it came from, rather than once per page.
160#[derive(Debug)]
161pub struct ShellTemplate {
162    segments: Vec<Segment>,
163}
164
165impl ShellTemplate {
166    /// Compile a template, rejecting unknown slot names and slots written with
167    /// the wrong braces for their kind.
168    pub fn parse(source: &str) -> Result<Self, ShellError> {
169        let bytes = source.as_bytes();
170        let mut segments = Vec::new();
171        let mut literal = String::new();
172        let mut i = 0;
173
174        while i < bytes.len() {
175            if bytes[i] == b'{'
176                && i + 1 < bytes.len()
177                && bytes[i + 1] == b'{'
178                && let Some((name, raw, consumed)) = scan_slot(&source[i..])
179            {
180                let index = slot_index(name, raw)?;
181                if !literal.is_empty() {
182                    segments.push(Segment::Literal(std::mem::take(&mut literal)));
183                }
184                segments.push(Segment::Slot(index));
185                i += consumed;
186                continue;
187            }
188
189            let ch = source[i..].chars().next().unwrap_or('\u{fffd}');
190            literal.push(ch);
191            i += ch.len_utf8();
192        }
193
194        if !literal.is_empty() {
195            segments.push(Segment::Literal(literal));
196        }
197        Ok(Self { segments })
198    }
199
200    /// Fill the template's slots.
201    pub fn render(&self, slots: &ShellSlots) -> String {
202        let mut out = String::new();
203        for segment in &self.segments {
204            match segment {
205                Segment::Literal(text) => out.push_str(text),
206                Segment::Slot(index) => {
207                    let (name, kind) = SLOTS[*index];
208                    let value = slot_value(slots, name);
209                    match kind {
210                        Kind::Text => out.push_str(&html_escape(value)),
211                        Kind::Raw => out.push_str(value),
212                    }
213                }
214            }
215        }
216        out
217    }
218}
219
220/// Look a slot name up, checking that the braces match its kind.
221fn slot_index(name: &str, raw: bool) -> Result<usize, ShellError> {
222    let Some(index) = SLOTS.iter().position(|(n, _)| *n == name) else {
223        let known: Vec<&str> = SLOTS.iter().map(|(n, _)| *n).collect();
224        return Err(ShellError(format!(
225            "unknown shell slot `{name}`. Known slots: {}",
226            known.join(", ")
227        )));
228    };
229    let (_, kind) = SLOTS[index];
230    match (kind, raw) {
231        (Kind::Text, false) | (Kind::Raw, true) => Ok(index),
232        (Kind::Text, true) => Err(ShellError(format!(
233            "shell slot `{name}` is text and is HTML-escaped; write it as {{{{{name}}}}}"
234        ))),
235        (Kind::Raw, false) => Err(ShellError(format!(
236            "shell slot `{name}` is raw HTML; write it as {{{{{{{name}}}}}}}"
237        ))),
238    }
239}
240
241fn slot_value<'a>(slots: &'a ShellSlots, name: &str) -> &'a str {
242    match name {
243        "lang" => &slots.lang,
244        "document_title" => &slots.document_title,
245        "site_title" => &slots.site_title,
246        "body_class" => &slots.body_class,
247        "root_prefix" => &slots.root_prefix,
248        "head" => &slots.head,
249        "site_nav" => &slots.site_nav,
250        "breadcrumbs" => &slots.breadcrumbs,
251        "toc" => &slots.toc,
252        "site_header" => &slots.site_header,
253        "content" => &slots.content,
254        "pager" => &slots.pager,
255        "site_footer" => &slots.site_footer,
256        "footer" => &slots.footer,
257        "scripts" => &slots.scripts,
258        // Unreachable: `slot_index` accepted the name against the same table.
259        _ => "",
260    }
261}
262
263/// Read a slot reference off the front of `s`, which is known to start `{{`.
264///
265/// Returns the slot name, whether it was written with three braces, and how many
266/// bytes it occupied. `None` when what follows is not a well-formed reference —
267/// which is how a template carrying an inline script or a CSS block keeps its
268/// braces.
269fn scan_slot(s: &str) -> Option<(&str, bool, usize)> {
270    let raw = s.as_bytes().get(2) == Some(&b'{');
271    let open = if raw { 3 } else { 2 };
272    let close = if raw { "}}}" } else { "}}" };
273
274    let after_open = s.get(open..)?;
275    let name_start = after_open.len() - after_open.trim_start_matches([' ', '\t']).len();
276    let name_region = &after_open[name_start..];
277    let name_len = name_region
278        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
279        .unwrap_or(name_region.len());
280    if name_len == 0 {
281        return None;
282    }
283    let name = &name_region[..name_len];
284
285    let after_name = &name_region[name_len..];
286    let pad = after_name.len() - after_name.trim_start_matches([' ', '\t']).len();
287    if !after_name[pad..].starts_with(close) {
288        return None;
289    }
290
291    Some((name, raw, open + name_start + name_len + pad + close.len()))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    fn slots() -> ShellSlots {
299        ShellSlots {
300            lang: "en".into(),
301            document_title: "A & B".into(),
302            site_title: "<Site>".into(),
303            body_class: "has-site-nav".into(),
304            root_prefix: "../".into(),
305            head: r#"<link rel="stylesheet" href="style.css">"#.into(),
306            site_nav: "<nav>n</nav>".into(),
307            breadcrumbs: "<p>b</p>".into(),
308            toc: "<nav>t</nav>".into(),
309            site_header: "<p>h</p>".into(),
310            content: "<p>Hello</p>".into(),
311            pager: "<nav>p</nav>".into(),
312            site_footer: "<p>sf</p>".into(),
313            footer: "<footer>f</footer>".into(),
314            scripts: "<script>s</script>".into(),
315        }
316    }
317
318    #[test]
319    fn text_slots_are_escaped_and_raw_slots_are_not() {
320        let t = ShellTemplate::parse("<title>{{document_title}}</title>{{{content}}}").unwrap();
321        assert_eq!(
322            t.render(&slots()),
323            "<title>A &amp; B</title><p>Hello</p>",
324            "text escaped, HTML passed through"
325        );
326    }
327
328    #[test]
329    fn whitespace_inside_the_braces_is_allowed() {
330        let t = ShellTemplate::parse("{{ site_title }}|{{{ site_nav }}}").unwrap();
331        assert_eq!(t.render(&slots()), "&lt;Site&gt;|<nav>n</nav>");
332    }
333
334    #[test]
335    fn every_slot_is_reachable() {
336        let source: String = SLOTS
337            .iter()
338            .map(|(name, kind)| match kind {
339                Kind::Text => format!("[{{{{{name}}}}}]"),
340                Kind::Raw => format!("[{{{{{{{name}}}}}}}]"),
341            })
342            .collect();
343        let out = ShellTemplate::parse(&source).unwrap().render(&slots());
344        assert!(out.contains("[en]"));
345        assert!(out.contains("[has-site-nav]"));
346        assert!(out.contains("[../]"));
347        assert!(out.contains("[<nav>t</nav>]"));
348        assert!(out.contains("[<p>h</p>]"));
349        assert!(out.contains("[<nav>p</nav>]"));
350        assert!(out.contains("[<p>sf</p>]"));
351        assert!(out.contains("[<footer>f</footer>]"));
352        assert!(!out.contains("{{"), "nothing was left unfilled: {out}");
353    }
354
355    #[test]
356    fn an_unknown_slot_is_an_error_naming_the_known_ones() {
357        let err = ShellTemplate::parse("{{titel}}").unwrap_err();
358        assert!(err.message().contains("unknown shell slot `titel`"));
359        assert!(err.message().contains("document_title"));
360    }
361
362    #[test]
363    fn a_raw_slot_written_as_text_is_an_error_rather_than_escaped_html() {
364        let err = ShellTemplate::parse("{{content}}").unwrap_err();
365        assert!(err.message().contains("raw HTML"), "{err}");
366        assert!(err.message().contains("{{{content}}}"), "{err}");
367    }
368
369    #[test]
370    fn a_text_slot_written_as_raw_is_an_error_rather_than_unescaped_text() {
371        let err = ShellTemplate::parse("{{{site_title}}}").unwrap_err();
372        assert!(err.message().contains("HTML-escaped"), "{err}");
373        assert!(err.message().contains("{{site_title}}"), "{err}");
374    }
375
376    /// A shell carrying an inline script or a CSS block must survive it: braces
377    /// that are not a slot reference are not the substitutor's business.
378    #[test]
379    fn braces_that_are_not_slots_pass_through() {
380        let source = "<style>a{b:c}</style><script>if(x){{y()}}</script>{{}}{ {a} }";
381        let t = ShellTemplate::parse(source).unwrap();
382        assert_eq!(t.render(&slots()), source);
383    }
384
385    #[test]
386    fn a_template_with_no_slots_is_itself() {
387        let t = ShellTemplate::parse("<p>plain</p>").unwrap();
388        assert_eq!(t.render(&ShellSlots::default()), "<p>plain</p>");
389    }
390}