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