webserver_base/templates/
registry.rs1use 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
14pub const TEMPLATE_DIRECTORIES: [&str; 3] = ["layouts", "pages", "partials"];
17
18pub const TEMPLATE_ROOT: &str = "html";
21
22pub const BASE_TEMPLATE_NAME: &str = "base";
24
25pub const NOT_FOUND_TEMPLATE_NAME: &str = "404";
31
32const BASE_TEMPLATE: &str = include_str!("../../assets/html/layouts/base.hbs");
39
40handlebars_helper!(join: |list: Vec<String>| list.join(","));
42
43handlebars_helper!(pretty_date: |date: DateTime<Utc>| date.format("%B %e, %Y").to_string());
45
46handlebars_helper!(has_key: |object: Value, key: str| {
48 object.as_object().is_some_and(|object| object.contains_key(key))
49});
50
51#[derive(Clone)]
53pub struct TemplateRegistry<'a> {
54 handlebars: Handlebars<'a>,
55}
56
57impl<'a> TemplateRegistry<'a> {
58 #[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 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 #[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 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 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 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 #[must_use]
182 pub fn has_template(&self, name: &str) -> bool {
183 self.handlebars.has_template(name)
184 }
185
186 #[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}