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 crate already links handlebars for *body* templating, and every caller
13//! that can reach [`crate::site`] enables the `templating` feature — so reusing
14//! it was open. Three things argued against it:
15//!
16//! - Handlebars escapes with its own rule (backtick and `=` included), so the
17//!   shell would escape titles differently from [`crate::page::html_escape`],
18//!   which is the one escaping rule the rest of the crate uses.
19//! - A misspelled variable renders empty in non-strict mode and errors on
20//!   *every* absent field in strict mode; neither is "tell me about the typo,
21//!   and only the typo".
22//! - [`crate::html`] is compiled without the `templating` feature, and the shell
23//!   is assembled there. A handlebars shell would either drag the dependency
24//!   into the feature-free build or split page assembly across a feature gate.
25//!
26//! So the substitutor here is deliberately small: named slots, no expressions,
27//! no control flow. Anything a shell wants to vary per page it varies by
28//! rendering a different site.
29//!
30//! ## Syntax
31//!
32//! `{{name}}` inserts a **text** slot, HTML-escaped. `{{{name}}}` inserts a
33//! **raw HTML** slot verbatim. Whitespace inside the braces is allowed
34//! (`{{ site_title }}`). The two spellings are not interchangeable: each slot is
35//! one kind or the other, and writing it the other way is an error rather than a
36//! silently escaped `<div>`. Anything that is not a well-formed slot reference —
37//! `{{` in an inline script, a CSS block, a `{{}}` with no name — passes through
38//! literally.
39
40use crate::page::html_escape;
41
42/// The named values a shell template is filled with.
43///
44/// Text fields hold their *unescaped* text; escaping happens where the slot is
45/// filled, so a value is escaped exactly once no matter which shell renders it.
46#[derive(Debug, Clone, Default)]
47pub struct ShellSlots {
48    /// `{{lang}}` — the document language, for `<html lang="…">`.
49    pub lang: String,
50    /// `{{document_title}}` — the `<title>` text: `"Entry - Site"`, or just the
51    /// site's name on the page that *is* the site.
52    pub document_title: String,
53    /// `{{site_title}}` — the site's name on its own.
54    pub site_title: String,
55    /// `{{body_class}}` — the class list for `<body>`, to be written *inside*
56    /// `class="…"`. Empty when the page has no site nav.
57    pub body_class: String,
58    /// `{{{head}}}` — stylesheet link, favicon link, SEO meta, feed links and
59    /// the page's own `styles:`, as a newline-separated run of tags indented
60    /// four spaces. Does **not** include `<title>`, which is its own slot.
61    pub head: String,
62    /// `{{{site_nav}}}` — the site navigation sidebar. Empty when the site has
63    /// no nav tree.
64    pub site_nav: String,
65    /// `{{{breadcrumbs}}}` — the breadcrumb trail for this page.
66    pub breadcrumbs: String,
67    /// `{{{content}}}` — the rendered body, with its links already rewritten.
68    pub content: String,
69    /// `{{{footer}}}` — the built-in attribution footer.
70    pub footer: String,
71    /// `{{{scripts}}}` — the built-in interactivity script and the page's own
72    /// `scripts:`, as a newline-separated run of tags indented four spaces.
73    pub scripts: String,
74}
75
76/// Whether a slot is text (escaped on the way in) or raw HTML.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum Kind {
79    Text,
80    Raw,
81}
82
83/// Every slot a template may name, with its kind. The order is the order the
84/// error message lists them in.
85const SLOTS: &[(&str, Kind)] = &[
86    ("lang", Kind::Text),
87    ("document_title", Kind::Text),
88    ("site_title", Kind::Text),
89    ("body_class", Kind::Text),
90    ("head", Kind::Raw),
91    ("site_nav", Kind::Raw),
92    ("breadcrumbs", Kind::Raw),
93    ("content", Kind::Raw),
94    ("footer", Kind::Raw),
95    ("scripts", Kind::Raw),
96];
97
98/// A shell template that could not be compiled. Carries a message written for
99/// whoever wrote the template, since that is the only person who can fix it.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ShellError(String);
102
103impl ShellError {
104    /// The message, for a caller that reports it its own way.
105    pub fn message(&self) -> &str {
106        &self.0
107    }
108}
109
110impl std::fmt::Display for ShellError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.write_str(&self.0)
113    }
114}
115
116impl std::error::Error for ShellError {}
117
118/// One piece of a compiled template.
119#[derive(Debug)]
120enum Segment {
121    Literal(String),
122    /// An index into [`SLOTS`], resolved at compile time so rendering is a
123    /// lookup rather than a second parse.
124    Slot(usize),
125}
126
127/// A compiled shell template.
128///
129/// Compiling is separate from rendering so a bad template is reported once,
130/// against the file it came from, rather than once per page.
131#[derive(Debug)]
132pub struct ShellTemplate {
133    segments: Vec<Segment>,
134}
135
136impl ShellTemplate {
137    /// Compile a template, rejecting unknown slot names and slots written with
138    /// the wrong braces for their kind.
139    pub fn parse(source: &str) -> Result<Self, ShellError> {
140        let bytes = source.as_bytes();
141        let mut segments = Vec::new();
142        let mut literal = String::new();
143        let mut i = 0;
144
145        while i < bytes.len() {
146            if bytes[i] == b'{'
147                && i + 1 < bytes.len()
148                && bytes[i + 1] == b'{'
149                && let Some((name, raw, consumed)) = scan_slot(&source[i..])
150            {
151                let index = slot_index(name, raw)?;
152                if !literal.is_empty() {
153                    segments.push(Segment::Literal(std::mem::take(&mut literal)));
154                }
155                segments.push(Segment::Slot(index));
156                i += consumed;
157                continue;
158            }
159
160            let ch = source[i..].chars().next().unwrap_or('\u{fffd}');
161            literal.push(ch);
162            i += ch.len_utf8();
163        }
164
165        if !literal.is_empty() {
166            segments.push(Segment::Literal(literal));
167        }
168        Ok(Self { segments })
169    }
170
171    /// Fill the template's slots.
172    pub fn render(&self, slots: &ShellSlots) -> String {
173        let mut out = String::new();
174        for segment in &self.segments {
175            match segment {
176                Segment::Literal(text) => out.push_str(text),
177                Segment::Slot(index) => {
178                    let (name, kind) = SLOTS[*index];
179                    let value = slot_value(slots, name);
180                    match kind {
181                        Kind::Text => out.push_str(&html_escape(value)),
182                        Kind::Raw => out.push_str(value),
183                    }
184                }
185            }
186        }
187        out
188    }
189}
190
191/// Look a slot name up, checking that the braces match its kind.
192fn slot_index(name: &str, raw: bool) -> Result<usize, ShellError> {
193    let Some(index) = SLOTS.iter().position(|(n, _)| *n == name) else {
194        let known: Vec<&str> = SLOTS.iter().map(|(n, _)| *n).collect();
195        return Err(ShellError(format!(
196            "unknown shell slot `{name}`. Known slots: {}",
197            known.join(", ")
198        )));
199    };
200    let (_, kind) = SLOTS[index];
201    match (kind, raw) {
202        (Kind::Text, false) | (Kind::Raw, true) => Ok(index),
203        (Kind::Text, true) => Err(ShellError(format!(
204            "shell slot `{name}` is text and is HTML-escaped; write it as {{{{{name}}}}}"
205        ))),
206        (Kind::Raw, false) => Err(ShellError(format!(
207            "shell slot `{name}` is raw HTML; write it as {{{{{{{name}}}}}}}"
208        ))),
209    }
210}
211
212fn slot_value<'a>(slots: &'a ShellSlots, name: &str) -> &'a str {
213    match name {
214        "lang" => &slots.lang,
215        "document_title" => &slots.document_title,
216        "site_title" => &slots.site_title,
217        "body_class" => &slots.body_class,
218        "head" => &slots.head,
219        "site_nav" => &slots.site_nav,
220        "breadcrumbs" => &slots.breadcrumbs,
221        "content" => &slots.content,
222        "footer" => &slots.footer,
223        "scripts" => &slots.scripts,
224        // Unreachable: `slot_index` accepted the name against the same table.
225        _ => "",
226    }
227}
228
229/// Read a slot reference off the front of `s`, which is known to start `{{`.
230///
231/// Returns the slot name, whether it was written with three braces, and how many
232/// bytes it occupied. `None` when what follows is not a well-formed reference —
233/// which is how a template carrying an inline script or a CSS block keeps its
234/// braces.
235fn scan_slot(s: &str) -> Option<(&str, bool, usize)> {
236    let raw = s.as_bytes().get(2) == Some(&b'{');
237    let open = if raw { 3 } else { 2 };
238    let close = if raw { "}}}" } else { "}}" };
239
240    let after_open = s.get(open..)?;
241    let name_start = after_open.len() - after_open.trim_start_matches([' ', '\t']).len();
242    let name_region = &after_open[name_start..];
243    let name_len = name_region
244        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
245        .unwrap_or(name_region.len());
246    if name_len == 0 {
247        return None;
248    }
249    let name = &name_region[..name_len];
250
251    let after_name = &name_region[name_len..];
252    let pad = after_name.len() - after_name.trim_start_matches([' ', '\t']).len();
253    if !after_name[pad..].starts_with(close) {
254        return None;
255    }
256
257    Some((name, raw, open + name_start + name_len + pad + close.len()))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn slots() -> ShellSlots {
265        ShellSlots {
266            lang: "en".into(),
267            document_title: "A & B".into(),
268            site_title: "<Site>".into(),
269            body_class: "has-site-nav".into(),
270            head: r#"<link rel="stylesheet" href="style.css">"#.into(),
271            site_nav: "<nav>n</nav>".into(),
272            breadcrumbs: "<p>b</p>".into(),
273            content: "<p>Hello</p>".into(),
274            footer: "<footer>f</footer>".into(),
275            scripts: "<script>s</script>".into(),
276        }
277    }
278
279    #[test]
280    fn text_slots_are_escaped_and_raw_slots_are_not() {
281        let t = ShellTemplate::parse("<title>{{document_title}}</title>{{{content}}}").unwrap();
282        assert_eq!(
283            t.render(&slots()),
284            "<title>A &amp; B</title><p>Hello</p>",
285            "text escaped, HTML passed through"
286        );
287    }
288
289    #[test]
290    fn whitespace_inside_the_braces_is_allowed() {
291        let t = ShellTemplate::parse("{{ site_title }}|{{{ site_nav }}}").unwrap();
292        assert_eq!(t.render(&slots()), "&lt;Site&gt;|<nav>n</nav>");
293    }
294
295    #[test]
296    fn every_slot_is_reachable() {
297        let source: String = SLOTS
298            .iter()
299            .map(|(name, kind)| match kind {
300                Kind::Text => format!("[{{{{{name}}}}}]"),
301                Kind::Raw => format!("[{{{{{{{name}}}}}}}]"),
302            })
303            .collect();
304        let out = ShellTemplate::parse(&source).unwrap().render(&slots());
305        assert!(out.contains("[en]"));
306        assert!(out.contains("[has-site-nav]"));
307        assert!(out.contains("[<footer>f</footer>]"));
308        assert!(!out.contains("{{"), "nothing was left unfilled: {out}");
309    }
310
311    #[test]
312    fn an_unknown_slot_is_an_error_naming_the_known_ones() {
313        let err = ShellTemplate::parse("{{titel}}").unwrap_err();
314        assert!(err.message().contains("unknown shell slot `titel`"));
315        assert!(err.message().contains("document_title"));
316    }
317
318    #[test]
319    fn a_raw_slot_written_as_text_is_an_error_rather_than_escaped_html() {
320        let err = ShellTemplate::parse("{{content}}").unwrap_err();
321        assert!(err.message().contains("raw HTML"), "{err}");
322        assert!(err.message().contains("{{{content}}}"), "{err}");
323    }
324
325    #[test]
326    fn a_text_slot_written_as_raw_is_an_error_rather_than_unescaped_text() {
327        let err = ShellTemplate::parse("{{{site_title}}}").unwrap_err();
328        assert!(err.message().contains("HTML-escaped"), "{err}");
329        assert!(err.message().contains("{{site_title}}"), "{err}");
330    }
331
332    /// A shell carrying an inline script or a CSS block must survive it: braces
333    /// that are not a slot reference are not the substitutor's business.
334    #[test]
335    fn braces_that_are_not_slots_pass_through() {
336        let source = "<style>a{b:c}</style><script>if(x){{y()}}</script>{{}}{ {a} }";
337        let t = ShellTemplate::parse(source).unwrap();
338        assert_eq!(t.render(&slots()), source);
339    }
340
341    #[test]
342    fn a_template_with_no_slots_is_itself() {
343        let t = ShellTemplate::parse("<p>plain</p>").unwrap();
344        assert_eq!(t.render(&ShellSlots::default()), "<p>plain</p>");
345    }
346}