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::read_dir(&path).map_err(|source| TemplateError::ReadDirectory {
118 path: path.clone(),
119 source,
120 })?;
121
122 for entry in entries {
123 let entry = entry.map_err(|source| TemplateError::ReadDirectory {
124 path: path.clone(),
125 source,
126 })?;
127 let file: PathBuf = entry.path();
128 if file.is_dir() {
129 continue;
130 }
131 let Some(name) = file.file_stem().and_then(|stem| stem.to_str()) else {
132 continue;
133 };
134
135 if name == BASE_TEMPLATE_NAME {
139 return Err(TemplateError::ReservedName {
140 name: BASE_TEMPLATE_NAME,
141 path: file,
142 });
143 }
144
145 if directory == "pages" {
146 pages += 1;
147 }
148
149 registry
150 .handlebars
151 .register_template_file(name, &file)
152 .map_err(|source| TemplateError::Compile {
153 name: name.to_string(),
154 source: Box::new(source),
155 })?;
156 }
157 }
158
159 if pages == 0 {
160 return Err(TemplateError::NoPages {
161 path: root.join("pages"),
162 });
163 }
164
165 if !registry.has_template(NOT_FOUND_TEMPLATE_NAME) {
166 return Err(TemplateError::MissingNotFoundPage {
167 path: root.join("pages").join("404.hbs"),
168 });
169 }
170
171 Ok(registry)
172 }
173
174 pub fn register_helper(&mut self, name: &str, helper: Box<dyn HelperDef + Send + Sync + 'a>) {
176 self.handlebars.register_helper(name, helper);
177 }
178
179 #[must_use]
181 pub fn has_template(&self, name: &str) -> bool {
182 self.handlebars.has_template(name)
183 }
184
185 #[instrument(skip_all)]
192 pub fn render<T>(&self, name: &str, data: &T) -> Result<String, TemplateError>
193 where
194 T: Serialize,
195 {
196 self.handlebars
197 .render(name, data)
198 .map_err(|source| TemplateError::Render {
199 name: name.to_string(),
200 source: Box::new(source),
201 })
202 }
203}
204
205impl std::fmt::Debug for TemplateRegistry<'_> {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 let mut names: Vec<&String> = self.handlebars.get_templates().keys().collect();
208 names.sort();
209 f.debug_struct("TemplateRegistry")
210 .field("templates", &names)
211 .finish()
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use std::fs;
218 use std::path::PathBuf;
219
220 use serde_json::json;
221
222 use super::TemplateRegistry;
223 use crate::templates::error::TemplateError;
224
225 #[test]
226 fn a_registry_holds_the_embedded_layout_before_it_touches_any_file() {
227 let registry: TemplateRegistry<'_> = TemplateRegistry::empty();
228
229 let expected: bool = true;
230 let actual: bool = registry.has_template(super::BASE_TEMPLATE_NAME);
231 assert_eq!(expected, actual);
232 }
233
234 #[test]
235 fn a_registry_holding_only_the_layout_has_no_pages() {
236 let registry: TemplateRegistry<'_> = TemplateRegistry::empty();
237
238 let expected: bool = false;
239 let actual: bool = registry.has_template("home");
240 assert_eq!(expected, actual);
241 }
242
243 #[test]
244 fn strict_mode_turns_a_missing_field_into_an_error() {
245 let mut registry: TemplateRegistry<'_> = TemplateRegistry::empty();
246 registry
247 .handlebars
248 .register_template_string("greet", "hello {{name}}")
249 .expect("valid template");
250
251 let error: TemplateError = registry
252 .render("greet", &json!({ "other": "field" }))
253 .expect_err("strict mode rejects a missing field");
254 assert!(matches!(error, TemplateError::Render { ref name, .. } if name == "greet"));
255 }
256
257 #[test]
258 fn the_join_helper_comma_delimits_for_the_keywords_tag() {
259 let mut registry: TemplateRegistry<'_> = TemplateRegistry::empty();
260 registry
261 .handlebars
262 .register_template_string("keywords", "{{join words}}")
263 .expect("valid template");
264
265 let expected: String = String::from("boggle,word-game,scrabble");
266 let actual: String = registry
267 .render(
268 "keywords",
269 &json!({ "words": ["boggle", "word-game", "scrabble"] }),
270 )
271 .expect("renders");
272 assert_eq!(expected, actual);
273 }
274
275 #[test]
276 fn the_has_key_helper_distinguishes_present_from_absent() {
277 let mut registry: TemplateRegistry<'_> = TemplateRegistry::empty();
278 registry
279 .handlebars
280 .register_template_string("check", "{{#if (has_key map 'a')}}yes{{else}}no{{/if}}")
281 .expect("valid template");
282
283 let expected_present: String = String::from("yes");
284 let actual_present: String = registry
285 .render("check", &json!({ "map": { "a": 1 } }))
286 .expect("renders");
287 assert_eq!(expected_present, actual_present);
288
289 let expected_absent: String = String::from("no");
290 let actual_absent: String = registry
291 .render("check", &json!({ "map": { "b": 1 } }))
292 .expect("renders");
293 assert_eq!(expected_absent, actual_absent);
294 }
295
296 #[test]
297 fn a_frontend_without_a_template_root_cannot_start() {
298 let error: TemplateError = TemplateRegistry::from_dir("/tmp/wsb-nonexistent-template-root")
299 .expect_err("a frontend needs templates");
300 assert!(matches!(error, TemplateError::ReadDirectory { .. }));
301 }
302
303 #[test]
304 fn a_project_layout_named_base_is_refused_rather_than_silently_winning() {
305 let root: PathBuf = PathBuf::from("/tmp/wsb-reserved-name/html");
306 fs::create_dir_all(root.join("layouts")).expect("temp dirs");
307 fs::create_dir_all(root.join("pages")).expect("temp dirs");
308 fs::write(root.join("pages/home.hbs"), "hi").expect("temp file");
309 fs::write(root.join("pages/404.hbs"), "nope").expect("temp file");
310 fs::write(root.join("layouts/base.hbs"), "<html></html>").expect("temp file");
311
312 let error: TemplateError =
313 TemplateRegistry::from_dir(&root).expect_err("`base` is reserved");
314 assert!(matches!(error, TemplateError::ReservedName { name, .. } if name == "base"));
315
316 fs::remove_dir_all("/tmp/wsb-reserved-name").ok();
317 }
318
319 #[test]
320 fn a_project_may_add_any_other_layout() {
321 let root: PathBuf = PathBuf::from("/tmp/wsb-other-layout/html");
322 fs::create_dir_all(root.join("layouts")).expect("temp dirs");
323 fs::create_dir_all(root.join("pages")).expect("temp dirs");
324 fs::write(root.join("pages/home.hbs"), "hi").expect("temp file");
325 fs::write(root.join("pages/404.hbs"), "nope").expect("temp file");
326 fs::write(root.join("layouts/chapter.hbs"), "shell").expect("temp file");
327
328 let registry: TemplateRegistry<'_> =
329 TemplateRegistry::from_dir(&root).expect("a non-reserved layout is fine");
330
331 let expected: bool = true;
332 let actual: bool = registry.has_template("chapter");
333 assert_eq!(expected, actual);
334
335 fs::remove_dir_all("/tmp/wsb-other-layout").ok();
336 }
337
338 #[test]
339 fn a_frontend_with_no_pages_cannot_start() {
340 let root: PathBuf = PathBuf::from("/tmp/wsb-no-pages/html");
341 fs::create_dir_all(root.join("partials")).expect("temp dirs");
342 fs::write(root.join("partials/footer.hbs"), "<footer></footer>").expect("temp file");
343
344 let error: TemplateError =
345 TemplateRegistry::from_dir(&root).expect_err("a frontend must serve a page");
346 assert!(matches!(error, TemplateError::NoPages { .. }));
347
348 fs::remove_dir_all("/tmp/wsb-no-pages").ok();
349 }
350
351 #[test]
352 fn a_frontend_without_a_404_template_cannot_start() {
353 let root: PathBuf = PathBuf::from("/tmp/wsb-no-404/html");
354 fs::create_dir_all(root.join("pages")).expect("temp dirs");
355 fs::write(root.join("pages/home.hbs"), "hi").expect("temp file");
356
357 let error: TemplateError =
358 TemplateRegistry::from_dir(&root).expect_err("every frontend serves a 404");
359 assert!(matches!(error, TemplateError::MissingNotFoundPage { .. }));
360
361 fs::remove_dir_all("/tmp/wsb-no-404").ok();
362 }
363}