Skip to main content

webserver_base/templates/
registry.rs

1//! The Handlebars registry.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Utc};
7use handlebars::{Handlebars, HelperDef, handlebars_helper};
8use serde::Serialize;
9use serde_json::Value;
10use tracing::instrument;
11
12use super::error::TemplateError;
13
14/// The subdirectories scanned, in order. A template's name is its file stem, so
15/// `pages/home.hbs` is `{{> home}}`; stems must be unique across all three.
16pub const TEMPLATE_DIRECTORIES: [&str; 3] = ["layouts", "pages", "partials"];
17
18/// Where a project's templates live. Not configurable: every project uses this
19/// layout, and a knob that can only correctly hold one value is not a knob.
20pub const TEMPLATE_ROOT: &str = "html";
21
22/// The name of the embedded layout every page extends.
23pub const BASE_TEMPLATE_NAME: &str = "base";
24
25/// The 404 template every frontend must provide.
26///
27/// Its data is not a per-project decision — every site wants the same page name,
28/// the same `/404` URL and the same `noindex, follow` — so the library declares
29/// it. What it *looks* like is still entirely the project's.
30pub const NOT_FOUND_TEMPLATE_NAME: &str = "404";
31
32/// The layout itself, compiled into the crate.
33///
34/// Shipping it here rather than copying it into each project is the whole
35/// point: the `<head>` is pure function — spec conformance, Open Graph, JSON-LD
36/// — and solving it once means no project can drift into a stale or subtly
37/// wrong version of it.
38const BASE_TEMPLATE: &str = include_str!("../../assets/html/layouts/base.hbs");
39
40// Comma-joins a list of strings, for `<meta name="keywords">`.
41handlebars_helper!(join: |list: Vec<String>| list.join(","));
42
43// Renders a UTC date as "January 15, 1990".
44handlebars_helper!(pretty_date: |date: DateTime<Utc>| date.format("%B %e, %Y").to_string());
45
46// True when an object has the given key.
47handlebars_helper!(has_key: |object: Value, key: str| {
48    object.as_object().is_some_and(|object| object.contains_key(key))
49});
50
51/// Every Handlebars template the server can render.
52#[derive(Clone)]
53pub struct TemplateRegistry<'a> {
54    handlebars: Handlebars<'a>,
55}
56
57impl<'a> TemplateRegistry<'a> {
58    /// A registry holding only the embedded `base` layout, plus the built-in
59    /// helpers and strict mode. Touches no files.
60    ///
61    /// # Panics
62    ///
63    /// Never in practice: the only template registered is compiled into the
64    /// binary, so a failure here means this crate shipped a layout that does
65    /// not parse, which its own tests would have caught.
66    #[must_use]
67    pub fn empty() -> Self {
68        let mut handlebars: Handlebars<'a> = Handlebars::new();
69
70        handlebars.register_helper("join", Box::new(join));
71        handlebars.register_helper("pretty_date", Box::new(pretty_date));
72        handlebars.register_helper("has_key", Box::new(has_key));
73
74        // A missing field becomes an error rather than an empty string: a blank
75        // `<title>` is a bug that ships, a failed render is one that gets fixed.
76        handlebars.set_strict_mode(true);
77
78        handlebars
79            .register_template_string(BASE_TEMPLATE_NAME, BASE_TEMPLATE)
80            .expect("the embedded base layout compiles");
81
82        Self { handlebars }
83    }
84
85    /// Loads every template under `root`'s `layouts`, `pages` and `partials`,
86    /// on top of the embedded `base` layout.
87    ///
88    /// # Errors
89    ///
90    /// [`TemplateError::ReadDirectory`] if `root` or a subdirectory is
91    /// unreadable, [`TemplateError::ReservedName`] if a file would shadow the
92    /// embedded layout, [`TemplateError::NoPages`] if no page templates exist,
93    /// or [`TemplateError::Compile`] if a template does not compile.
94    #[instrument(skip_all)]
95    pub fn from_dir(root: impl AsRef<Path>) -> Result<Self, TemplateError> {
96        let root: &Path = root.as_ref();
97        let mut registry: Self = Self::empty();
98        let mut pages: usize = 0;
99
100        if !root.is_dir() {
101            return Err(TemplateError::ReadDirectory {
102                path: root.to_path_buf(),
103                source: std::io::Error::new(
104                    std::io::ErrorKind::NotFound,
105                    "a frontend needs a template directory",
106                ),
107            });
108        }
109
110        for directory in TEMPLATE_DIRECTORIES {
111            let path: PathBuf = root.join(directory);
112            if !path.is_dir() {
113                // A site with no partials is a site, not a misconfiguration.
114                continue;
115            }
116
117            let entries: fs::ReadDir =
118                fs::read_dir(&path).map_err(|source| TemplateError::ReadDirectory {
119                    path: path.clone(),
120                    source,
121                })?;
122
123            for entry in entries {
124                let entry: fs::DirEntry = entry.map_err(|source| TemplateError::ReadDirectory {
125                    path: path.clone(),
126                    source,
127                })?;
128                let file: PathBuf = entry.path();
129                if file.is_dir() {
130                    continue;
131                }
132                let Some(name) = file.file_stem().and_then(|stem| stem.to_str()) else {
133                    continue;
134                };
135
136                // Silently letting a project's file win would disable the
137                // embedded `<head>` and nobody would notice until the SEO tags
138                // went missing in production.
139                if name == BASE_TEMPLATE_NAME {
140                    return Err(TemplateError::ReservedName {
141                        name: BASE_TEMPLATE_NAME,
142                        path: file,
143                    });
144                }
145
146                if directory == "pages" {
147                    pages += 1;
148                }
149
150                registry
151                    .handlebars
152                    .register_template_file(name, &file)
153                    .map_err(|source| TemplateError::Compile {
154                        name: name.to_string(),
155                        source: Box::new(source),
156                    })?;
157            }
158        }
159
160        if pages == 0 {
161            return Err(TemplateError::NoPages {
162                path: root.join("pages"),
163            });
164        }
165
166        if !registry.has_template(NOT_FOUND_TEMPLATE_NAME) {
167            return Err(TemplateError::MissingNotFoundPage {
168                path: root.join("pages").join("404.hbs"),
169            });
170        }
171
172        Ok(registry)
173    }
174
175    /// Registers an extra helper, for a project that needs one.
176    pub fn register_helper(&mut self, name: &str, helper: Box<dyn HelperDef + Send + Sync + 'a>) {
177        self.handlebars.register_helper(name, helper);
178    }
179
180    /// Whether a template with this name is registered.
181    #[must_use]
182    pub fn has_template(&self, name: &str) -> bool {
183        self.handlebars.has_template(name)
184    }
185
186    /// Renders `name` against `data`.
187    ///
188    /// # Errors
189    ///
190    /// [`TemplateError::Render`], most often because strict mode caught a field
191    /// the template asked for and the data did not supply.
192    #[instrument(skip_all)]
193    pub fn render<T>(&self, name: &str, data: &T) -> Result<String, TemplateError>
194    where
195        T: Serialize,
196    {
197        self.handlebars
198            .render(name, data)
199            .map_err(|source| TemplateError::Render {
200                name: name.to_string(),
201                source: Box::new(source),
202            })
203    }
204}
205
206impl std::fmt::Debug for TemplateRegistry<'_> {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        let mut names: Vec<&String> = self.handlebars.get_templates().keys().collect();
209        names.sort();
210        f.debug_struct("TemplateRegistry")
211            .field("templates", &names)
212            .finish()
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use std::fs;
219    use std::path::PathBuf;
220
221    use serde_json::json;
222
223    use super::TemplateRegistry;
224    use crate::templates::error::TemplateError;
225
226    #[test]
227    fn a_registry_holds_the_embedded_layout_before_it_touches_any_file() {
228        let registry: TemplateRegistry<'_> = TemplateRegistry::empty();
229
230        let expected: bool = true;
231        let actual: bool = registry.has_template(super::BASE_TEMPLATE_NAME);
232        assert_eq!(expected, actual);
233    }
234
235    #[test]
236    fn a_registry_holding_only_the_layout_has_no_pages() {
237        let registry: TemplateRegistry<'_> = TemplateRegistry::empty();
238
239        let expected: bool = false;
240        let actual: bool = registry.has_template("home");
241        assert_eq!(expected, actual);
242    }
243
244    #[test]
245    fn strict_mode_turns_a_missing_field_into_an_error() {
246        let mut registry: TemplateRegistry<'_> = TemplateRegistry::empty();
247        registry
248            .handlebars
249            .register_template_string("greet", "hello {{name}}")
250            .expect("valid template");
251
252        let error: TemplateError = registry
253            .render("greet", &json!({ "other": "field" }))
254            .expect_err("strict mode rejects a missing field");
255        assert!(matches!(error, TemplateError::Render { ref name, .. } if name == "greet"));
256    }
257
258    #[test]
259    fn the_join_helper_comma_delimits_for_the_keywords_tag() {
260        let mut registry: TemplateRegistry<'_> = TemplateRegistry::empty();
261        registry
262            .handlebars
263            .register_template_string("keywords", "{{join words}}")
264            .expect("valid template");
265
266        let expected: String = String::from("boggle,word-game,scrabble");
267        let actual: String = registry
268            .render(
269                "keywords",
270                &json!({ "words": ["boggle", "word-game", "scrabble"] }),
271            )
272            .expect("renders");
273        assert_eq!(expected, actual);
274    }
275
276    #[test]
277    fn the_has_key_helper_distinguishes_present_from_absent() {
278        let mut registry: TemplateRegistry<'_> = TemplateRegistry::empty();
279        registry
280            .handlebars
281            .register_template_string("check", "{{#if (has_key map 'a')}}yes{{else}}no{{/if}}")
282            .expect("valid template");
283
284        let expected_present: String = String::from("yes");
285        let actual_present: String = registry
286            .render("check", &json!({ "map": { "a": 1 } }))
287            .expect("renders");
288        assert_eq!(expected_present, actual_present);
289
290        let expected_absent: String = String::from("no");
291        let actual_absent: String = registry
292            .render("check", &json!({ "map": { "b": 1 } }))
293            .expect("renders");
294        assert_eq!(expected_absent, actual_absent);
295    }
296
297    #[test]
298    fn a_frontend_without_a_template_root_cannot_start() {
299        let error: TemplateError = TemplateRegistry::from_dir("/tmp/wsb-nonexistent-template-root")
300            .expect_err("a frontend needs templates");
301        assert!(matches!(error, TemplateError::ReadDirectory { .. }));
302    }
303
304    #[test]
305    fn a_project_layout_named_base_is_refused_rather_than_silently_winning() {
306        let root: PathBuf = PathBuf::from("/tmp/wsb-reserved-name/html");
307        fs::create_dir_all(root.join("layouts")).expect("temp dirs");
308        fs::create_dir_all(root.join("pages")).expect("temp dirs");
309        fs::write(root.join("pages/home.hbs"), "hi").expect("temp file");
310        fs::write(root.join("pages/404.hbs"), "nope").expect("temp file");
311        fs::write(root.join("layouts/base.hbs"), "<html></html>").expect("temp file");
312
313        let error: TemplateError =
314            TemplateRegistry::from_dir(&root).expect_err("`base` is reserved");
315        assert!(matches!(error, TemplateError::ReservedName { name, .. } if name == "base"));
316
317        fs::remove_dir_all("/tmp/wsb-reserved-name").ok();
318    }
319
320    #[test]
321    fn a_project_may_add_any_other_layout() {
322        let root: PathBuf = PathBuf::from("/tmp/wsb-other-layout/html");
323        fs::create_dir_all(root.join("layouts")).expect("temp dirs");
324        fs::create_dir_all(root.join("pages")).expect("temp dirs");
325        fs::write(root.join("pages/home.hbs"), "hi").expect("temp file");
326        fs::write(root.join("pages/404.hbs"), "nope").expect("temp file");
327        fs::write(root.join("layouts/chapter.hbs"), "shell").expect("temp file");
328
329        let registry: TemplateRegistry<'_> =
330            TemplateRegistry::from_dir(&root).expect("a non-reserved layout is fine");
331
332        let expected: bool = true;
333        let actual: bool = registry.has_template("chapter");
334        assert_eq!(expected, actual);
335
336        fs::remove_dir_all("/tmp/wsb-other-layout").ok();
337    }
338
339    #[test]
340    fn a_frontend_with_no_pages_cannot_start() {
341        let root: PathBuf = PathBuf::from("/tmp/wsb-no-pages/html");
342        fs::create_dir_all(root.join("partials")).expect("temp dirs");
343        fs::write(root.join("partials/footer.hbs"), "<footer></footer>").expect("temp file");
344
345        let error: TemplateError =
346            TemplateRegistry::from_dir(&root).expect_err("a frontend must serve a page");
347        assert!(matches!(error, TemplateError::NoPages { .. }));
348
349        fs::remove_dir_all("/tmp/wsb-no-pages").ok();
350    }
351
352    #[test]
353    fn a_frontend_without_a_404_template_cannot_start() {
354        let root: PathBuf = PathBuf::from("/tmp/wsb-no-404/html");
355        fs::create_dir_all(root.join("pages")).expect("temp dirs");
356        fs::write(root.join("pages/home.hbs"), "hi").expect("temp file");
357
358        let error: TemplateError =
359            TemplateRegistry::from_dir(&root).expect_err("every frontend serves a 404");
360        assert!(matches!(error, TemplateError::MissingNotFoundPage { .. }));
361
362        fs::remove_dir_all("/tmp/wsb-no-404").ok();
363    }
364}