1use std::{
2 collections::HashMap,
3 path::{Path, PathBuf},
4 sync::Arc,
5};
6
7use crate::contact::humanize_date;
8use crate::errors::TemplateError;
9use crate::markdown::SimpleMetadata;
10use itertools::Itertools;
11use parking_lot::RwLock;
12use tera::{Context, Tera};
13
14#[derive(Clone)]
15pub struct Templates {
16 tera: Arc<RwLock<Tera>>,
17 template_path: PathBuf,
19}
20
21fn contains_html_file(dir: &Path) -> bool {
26 walkdir::WalkDir::new(dir)
27 .into_iter()
28 .filter_map(Result::ok)
29 .any(|entry| is_html_file(&entry))
30}
31
32fn is_html_file(entry: &walkdir::DirEntry) -> bool {
34 entry.file_type().is_file()
35 && entry
36 .path()
37 .extension()
38 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
39}
40
41pub fn escape_json_for_script(json: &str) -> String {
55 json.replace('<', "\\u003c")
56 .replace('>', "\\u003e")
57 .replace('&', "\\u0026")
58}
59
60fn json_for_script(value: &serde_json::Value) -> String {
65 escape_json_for_script(&serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()))
66}
67
68fn nav_page_json(page: Option<&serde_json::Value>) -> String {
76 match page {
77 Some(page) => json_for_script(&serde_json::json!({
78 "url": page.get("url").and_then(|v| v.as_str()).unwrap_or_default(),
79 "title": page.get("title").and_then(|v| v.as_str()).unwrap_or_default(),
80 })),
81 None => "null".to_string(),
82 }
83}
84
85fn collect_user_templates(dir: &Path) -> Vec<(String, String)> {
91 let mut templates: Vec<(String, String)> = walkdir::WalkDir::new(dir)
92 .into_iter()
93 .filter_map(Result::ok)
94 .filter(is_html_file)
95 .filter_map(|entry| {
96 let name = entry
97 .path()
98 .strip_prefix(dir)
99 .ok()?
100 .to_string_lossy()
101 .replace('\\', "/");
102 let contents = std::fs::read_to_string(entry.path())
103 .inspect_err(|e| {
104 tracing::warn!(
105 "Skipping unreadable user template {}: {}",
106 entry.path().display(),
107 e
108 )
109 })
110 .ok()?;
111 Some((name, contents))
112 })
113 .collect();
114 templates.sort_by(|(a, _), (b, _)| a.cmp(b));
115 templates
116}
117
118fn load_tera_per_file(template_path: &Path) -> Tera {
130 let mut tera = Tera::default();
131 let mut pending = collect_user_templates(template_path);
132
133 while !pending.is_empty() {
134 let attempted = pending.len();
135 let mut failed: Vec<(String, String, tera::Error)> = Vec::new();
136
137 for (name, contents) in std::mem::take(&mut pending) {
138 let mut candidate = tera.clone();
139 match candidate.add_raw_template(&name, &contents) {
140 Ok(()) => tera = candidate,
141 Err(e) => failed.push((name, contents, e)),
142 }
143 }
144
145 if failed.len() == attempted {
148 for (name, _, e) in failed {
149 tracing::warn!(
150 "Skipping malformed user template {}: {}. Using the built-in default instead.",
151 name,
152 e
153 );
154 }
155 break;
156 }
157
158 pending = failed.into_iter().map(|(n, c, _)| (n, c)).collect();
159 }
160
161 tera
162}
163
164impl Templates {
165 pub fn new(root_path: &Path, template_folder: Option<&Path>) -> Result<Self, TemplateError> {
172 let template_path = if let Some(tf) = template_folder {
173 tf.to_path_buf()
174 } else {
175 root_path.join(".mbr")
176 };
177
178 let tera = Self::load_tera(&template_path)?;
179
180 Ok(Templates {
181 tera: Arc::new(RwLock::new(tera)),
182 template_path,
183 })
184 }
185
186 fn load_tera(template_path: &Path) -> Result<Tera, TemplateError> {
188 let source_desc = format!("{}", template_path.display());
189 let template_path_str = template_path
190 .to_str()
191 .ok_or(TemplateError::InvalidPathEncoding)?;
192
193 #[cfg(windows)]
201 let glob_base = template_path_str.replace('\\', "/");
202 #[cfg(not(windows))]
203 let glob_base = template_path_str.to_string();
204 let globs = format!("{}/**/*.html", glob_base.trim_end_matches('/'));
205
206 let mut tera = Tera::new(&globs).unwrap_or_else(|e| {
212 tracing::warn!(
213 "Failed to load user templates from {} as a set: {}. Retrying per file so one \
214 malformed template does not discard the others.",
215 source_desc,
216 e
217 );
218 load_tera_per_file(template_path)
219 });
220
221 if tera.get_template_names().next().is_none() && contains_html_file(template_path) {
228 tracing::warn!(
229 "No templates matched glob {} even though {} contains .html files. \
230 Using built-in defaults; user template overrides will be ignored.",
231 globs,
232 source_desc
233 );
234 }
235
236 tera.register_filter("humandate", humandate_filter);
240 tera.register_function("icon", crate::icons::icon_function);
243
244 for (name, tpl) in DEFAULT_TEMPLATES.iter() {
245 if tera.get_template(name).is_err() {
246 tracing::debug!("Adding default template {}", name);
247 tera.add_raw_template(name, tpl)
248 .map_err(|e| TemplateError::RenderFailed {
249 template_name: name.to_string(),
250 source: e,
251 })?;
252 }
253 }
254
255 Ok(tera)
256 }
257
258 pub fn reload(&self) -> Result<(), TemplateError> {
260 tracing::info!("Reloading templates from {:?}", self.template_path);
261 let new_tera = Self::load_tera(&self.template_path)?;
262 *self.tera.write() = new_tera;
263 tracing::debug!("Templates reloaded successfully");
264 Ok(())
265 }
266
267 pub fn tera_clone(&self) -> Tera {
272 self.tera.read().clone()
273 }
274
275 pub fn render_markdown(
276 &self,
277 html: &str,
278 frontmatter: SimpleMetadata,
279 extra_context: HashMap<String, serde_json::Value>,
280 ) -> Result<String, TemplateError> {
281 let tera = self.tera.read();
282 Self::render_markdown_with_tera(&tera, html, frontmatter, extra_context)
283 }
284
285 pub fn render_markdown_with_tera(
290 tera: &Tera,
291 html: &str,
292 frontmatter: SimpleMetadata,
293 extra_context: HashMap<String, serde_json::Value>,
294 ) -> Result<String, TemplateError> {
295 tracing::debug!("frontmatter: {:?}", &frontmatter);
296
297 let frontmatter_json = escape_json_for_script(
301 &serde_json::to_string(&frontmatter).unwrap_or_else(|_| "{}".to_string()),
302 );
303
304 let headings_json = extra_context
309 .get("headings")
310 .map(json_for_script)
311 .unwrap_or_else(|| "[]".to_string());
312 let file_path_json = extra_context
313 .get("file_path")
314 .map(json_for_script)
315 .unwrap_or_else(|| "\"\"".to_string());
316 let prev_page_json = nav_page_json(extra_context.get("prev_page"));
317 let next_page_json = nav_page_json(extra_context.get("next_page"));
318
319 let mut context = Context::new();
320 frontmatter.iter().for_each(|(k, v)| {
321 if k != "style" {
325 context.insert(k, v);
326 }
327 });
328 let body_class = body_class_list(&frontmatter);
329 if !body_class.is_empty() {
332 context.insert("style", &body_class);
333 }
334 extra_context.iter().for_each(|(k, v)| {
336 context.insert(k, v);
337 });
338 context.insert("markdown", html);
339 context.insert("frontmatter_json", &frontmatter_json);
340 context.insert("headings_json", &headings_json);
341 context.insert("file_path_json", &file_path_json);
342 context.insert("prev_page_json", &prev_page_json);
343 context.insert("next_page_json", &next_page_json);
344
345 let html_output =
346 tera.render("index.html", &context)
347 .map_err(|e| TemplateError::RenderFailed {
348 template_name: "index.html".to_string(),
349 source: e,
350 })?;
351 Ok(html_output)
352 }
353
354 pub fn render_template_with_tera(
359 tera: &Tera,
360 template_name: &str,
361 context_data: HashMap<String, serde_json::Value>,
362 ) -> Result<String, TemplateError> {
363 let mut context = Context::new();
364 context_data.iter().for_each(|(k, v)| {
365 context.insert(k, v);
366 });
367 tera.render(template_name, &context)
368 .map_err(|e| TemplateError::RenderFailed {
369 template_name: template_name.to_string(),
370 source: e,
371 })
372 }
373
374 pub fn render_section(
375 &self,
376 context_data: HashMap<String, serde_json::Value>,
377 ) -> Result<String, TemplateError> {
378 let tera = self.tera.read();
379 Self::render_template_with_tera(&tera, "section.html", context_data)
380 }
381
382 pub fn render_home(
385 &self,
386 context_data: HashMap<String, serde_json::Value>,
387 ) -> Result<String, TemplateError> {
388 let tera = self.tera.read();
389 Self::render_template_with_tera(&tera, "home.html", context_data)
390 }
391
392 pub fn render_error(
403 &self,
404 context_data: HashMap<String, serde_json::Value>,
405 ) -> Result<String, TemplateError> {
406 let tera = self.tera.read();
407 Self::render_template_with_tera(&tera, "error.html", context_data)
408 }
409
410 pub fn render_tag(
422 &self,
423 context_data: HashMap<String, serde_json::Value>,
424 ) -> Result<String, TemplateError> {
425 let tera = self.tera.read();
426 Self::render_template_with_tera(&tera, "tag.html", context_data)
427 }
428
429 pub fn render_tag_index(
440 &self,
441 context_data: HashMap<String, serde_json::Value>,
442 ) -> Result<String, TemplateError> {
443 let tera = self.tera.read();
444 Self::render_template_with_tera(&tera, "tag_index.html", context_data)
445 }
446
447 pub fn render_media_viewer(
460 &self,
461 context_data: HashMap<String, serde_json::Value>,
462 ) -> Result<String, TemplateError> {
463 let tera = self.tera.read();
464 Self::render_template_with_tera(&tera, "media_viewer.html", context_data)
465 }
466}
467
468fn humandate_filter(
476 value: &serde_json::Value,
477 _args: &HashMap<String, serde_json::Value>,
478) -> tera::Result<serde_json::Value> {
479 match value {
480 serde_json::Value::String(s) => Ok(serde_json::Value::String(humanize_date(s))),
481 other => Ok(other.clone()),
482 }
483}
484
485fn body_class_list(frontmatter: &SimpleMetadata) -> String {
501 let type_class = frontmatter
502 .get("type")
503 .and_then(serde_json::Value::as_str)
504 .map(|raw| collapse_dashes(&crate::markdown::slugify(raw)))
505 .filter(|slug| !slug.is_empty());
506
507 type_class
508 .into_iter()
509 .chain(frontmatter.get("style").into_iter().flat_map(style_classes))
510 .unique()
511 .join(" ")
512}
513
514fn style_classes(value: &serde_json::Value) -> Vec<String> {
521 match value {
522 serde_json::Value::String(s) => split_classes(s),
523 serde_json::Value::Array(arr) => arr
524 .iter()
525 .filter_map(serde_json::Value::as_str)
526 .flat_map(split_classes)
527 .collect(),
528 other => vec![other.to_string()],
529 }
530}
531
532fn split_classes(s: &str) -> Vec<String> {
533 s.split_whitespace().map(str::to_string).collect()
534}
535
536fn collapse_dashes(slug: &str) -> String {
546 slug.split('-').filter(|part| !part.is_empty()).join("-")
547}
548
549const DEFAULT_TEMPLATES: &[(&str, &str)] = &[
550 ("_head.html", include_str!("../templates/_head.html")),
552 (
553 "_head_custom.html",
554 include_str!("../templates/_head_custom.html"),
555 ),
556 (
557 "_head_markdown.html",
558 include_str!("../templates/_head_markdown.html"),
559 ),
560 ("_nav.html", include_str!("../templates/_nav.html")),
561 (
562 "_breadcrumbs.html",
563 include_str!("../templates/_breadcrumbs.html"),
564 ),
565 ("_footer.html", include_str!("../templates/_footer.html")),
566 (
567 "_footer_custom.html",
568 include_str!("../templates/_footer_custom.html"),
569 ),
570 ("_scripts.html", include_str!("../templates/_scripts.html")),
571 (
572 "_display_enhancements.html",
573 include_str!("../templates/_display_enhancements.html"),
574 ),
575 (
576 "_contact_card.html",
577 include_str!("../templates/_contact_card.html"),
578 ),
579 (
581 "_person_infobox.html",
582 include_str!("../templates/_person_infobox.html"),
583 ),
584 ("index.html", include_str!("../templates/index.html")),
586 ("section.html", include_str!("../templates/section.html")),
587 ("home.html", include_str!("../templates/home.html")),
588 ("error.html", include_str!("../templates/error.html")),
589 ("tag.html", include_str!("../templates/tag.html")),
591 (
592 "tag_index.html",
593 include_str!("../templates/tag_index.html"),
594 ),
595 (
597 "media_viewer.html",
598 include_str!("../templates/media_viewer.html"),
599 ),
600];
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use serde_json::json;
606
607 #[test]
614 fn test_user_templates_are_loaded_from_mbr_folder() {
615 let tmp = tempfile::tempdir().unwrap();
616 let mbr_dir = tmp.path().join(".mbr");
617 std::fs::create_dir_all(mbr_dir.join("partials")).unwrap();
618
619 std::fs::write(mbr_dir.join("zz_custom_page.html"), "<p>custom</p>").unwrap();
622 std::fs::write(
623 mbr_dir.join("partials").join("zz_nested_page.html"),
624 "<p>nested</p>",
625 )
626 .unwrap();
627
628 let templates = Templates::new(tmp.path(), None).expect("templates should build");
629 let tera = templates.tera.read();
630 let names: Vec<String> = tera.get_template_names().map(String::from).collect();
631
632 assert!(
633 names.iter().any(|n| n == "zz_custom_page.html"),
634 "top-level user template should be loaded, got: {names:?}"
635 );
636 assert!(
639 names.iter().any(|n| n.ends_with("zz_nested_page.html")),
640 "nested user template should be loaded via `**`, got: {names:?}"
641 );
642 }
643
644 #[test]
648 fn test_templates_fall_back_to_defaults_without_user_html() {
649 let tmp = tempfile::tempdir().unwrap();
650 let mbr_dir = tmp.path().join(".mbr");
651 std::fs::create_dir_all(&mbr_dir).unwrap();
652 std::fs::write(mbr_dir.join("config.toml"), "theme = \"amber\"").unwrap();
653
654 let templates = Templates::new(tmp.path(), None).expect("templates should build");
655 let tera = templates.tera.read();
656
657 assert!(
658 tera.get_template_names().any(|n| n == "index.html"),
659 "built-in defaults should still be registered"
660 );
661 }
662
663 fn fm(pairs: &[(&str, serde_json::Value)]) -> SimpleMetadata {
669 pairs
670 .iter()
671 .map(|(k, v)| ((*k).to_string(), v.clone()))
672 .collect()
673 }
674
675 #[test]
676 fn test_body_class_style_string() {
677 assert_eq!(
678 body_class_list(&fm(&[("style", json!("slides"))])),
679 "slides"
680 );
681 }
682
683 #[test]
684 fn test_body_class_style_string_with_spaces() {
685 assert_eq!(
686 body_class_list(&fm(&[("style", json!("slides other"))])),
687 "slides other"
688 );
689 }
690
691 #[test]
692 fn test_body_class_style_array() {
693 assert_eq!(
694 body_class_list(&fm(&[("style", json!(["slides", "other"]))])),
695 "slides other"
696 );
697 assert_eq!(
698 body_class_list(&fm(&[("style", json!(["slides"]))])),
699 "slides"
700 );
701 }
702
703 #[test]
706 fn test_body_class_style_array_elements_are_split() {
707 assert_eq!(
708 body_class_list(&fm(&[("style", json!(["slides other", "third"]))])),
709 "slides other third"
710 );
711 }
712
713 #[test]
716 fn test_body_class_style_non_string_values() {
717 assert_eq!(
718 body_class_list(&fm(&[("style", json!(["slides", 42, null]))])),
719 "slides"
720 );
721 assert_eq!(body_class_list(&fm(&[("style", json!(null))])), "null");
722 assert_eq!(body_class_list(&fm(&[("style", json!(42))])), "42");
723 }
724
725 #[test]
726 fn test_body_class_style_array_empty() {
727 assert_eq!(body_class_list(&fm(&[("style", json!([]))])), "");
728 }
729
730 #[test]
732 fn test_body_class_type_only() {
733 assert_eq!(body_class_list(&fm(&[("type", json!("slides"))])), "slides");
734 }
735
736 #[test]
737 fn test_body_class_type_is_slugified() {
738 assert_eq!(
739 body_class_list(&fm(&[("type", json!("Meeting Notes"))])),
740 "meeting-notes"
741 );
742 assert_eq!(
743 body_class_list(&fm(&[("type", json!("Field Note!"))])),
744 "field-note"
745 );
746 }
747
748 #[test]
752 fn test_body_class_type_collapses_doubled_dashes() {
753 assert_eq!(
754 crate::markdown::slugify("Book Review (2024)"),
755 "book-review--2024",
756 "guards the premise: the shared slugifier really does double here"
757 );
758 assert_eq!(
759 body_class_list(&fm(&[("type", json!("Book Review (2024)"))])),
760 "book-review-2024"
761 );
762 assert_eq!(
763 body_class_list(&fm(&[("type", json!("Meeting, Notes"))])),
764 "meeting-notes"
765 );
766 }
767
768 #[test]
771 fn test_body_class_type_trims_edge_dashes() {
772 assert_eq!(body_class_list(&fm(&[("type", json!("-draft-"))])), "draft");
773 assert_eq!(body_class_list(&fm(&[("type", json!("---"))])), "");
774 }
775
776 #[test]
778 fn test_body_class_type_and_style_combined() {
779 assert_eq!(
780 body_class_list(&fm(&[
781 ("style", json!(["wide", "dark"])),
782 ("type", json!("person")),
783 ])),
784 "person wide dark"
785 );
786 }
787
788 #[test]
791 fn test_body_class_type_and_style_dedupe() {
792 assert_eq!(
793 body_class_list(&fm(&[
794 ("style", json!("slides")),
795 ("type", json!("slides"))
796 ])),
797 "slides"
798 );
799 assert_eq!(
800 body_class_list(&fm(&[
801 ("style", json!("wide slides wide")),
802 ("type", json!("slides")),
803 ])),
804 "slides wide"
805 );
806 }
807
808 #[test]
811 fn test_body_class_non_string_type_ignored() {
812 assert_eq!(
813 body_class_list(&fm(&[("type", json!(["person", "place"]))])),
814 ""
815 );
816 assert_eq!(body_class_list(&fm(&[("type", json!(42))])), "");
817 assert_eq!(body_class_list(&fm(&[("type", json!("!!!"))])), "");
819 assert_eq!(
820 body_class_list(&fm(&[("type", json!("!!!")), ("style", json!("wide"))])),
821 "wide"
822 );
823 }
824
825 #[test]
826 fn test_body_class_neither_field() {
827 assert_eq!(body_class_list(&fm(&[("title", json!("Hello"))])), "");
828 }
829
830 #[test]
831 fn test_humanize_date_full() {
832 assert_eq!(humanize_date("1855-10-30"), "October 30, 1855");
833 assert_eq!(humanize_date("1902-01-10"), "January 10, 1902");
834 }
835
836 #[test]
837 fn test_humanize_date_full_strips_leading_zero_day() {
838 assert_eq!(humanize_date("1855-10-05"), "October 5, 1855");
839 assert_eq!(humanize_date("2000-12-01"), "December 1, 2000");
840 }
841
842 #[test]
843 fn test_humanize_date_year_month() {
844 assert_eq!(humanize_date("1855-10"), "October 1855");
845 assert_eq!(humanize_date("1902-01"), "January 1902");
846 }
847
848 #[test]
849 fn test_humanize_date_year_only_unchanged() {
850 assert_eq!(humanize_date("1855"), "1855");
851 }
852
853 #[test]
854 fn test_humanize_date_invalid_passthrough() {
855 assert_eq!(humanize_date("2020-13-40"), "2020-13-40");
858 assert_eq!(humanize_date("2020-00-10"), "2020-00-10");
859 assert_eq!(humanize_date("2020-02-32"), "2020-02-32");
860 assert_eq!(humanize_date("circa 1855"), "circa 1855");
861 assert_eq!(humanize_date("October 30, 1855"), "October 30, 1855");
862 assert_eq!(humanize_date("1855-1-1"), "1855-1-1"); assert_eq!(humanize_date("55-10-30"), "55-10-30"); assert_eq!(humanize_date(""), "");
865 }
866
867 #[test]
868 fn test_humandate_filter_string_and_passthrough() {
869 let args = HashMap::new();
870 assert_eq!(
871 humandate_filter(&json!("1855-10-30"), &args).unwrap(),
872 json!("October 30, 1855")
873 );
874 assert_eq!(humandate_filter(&json!(1855), &args).unwrap(), json!(1855));
876 assert_eq!(humandate_filter(&json!(null), &args).unwrap(), json!(null));
877 }
878
879 #[test]
880 fn test_humandate_filter_registered_in_tera() {
881 let mut tera = Tera::default();
882 tera.register_filter("humandate", humandate_filter);
883 tera.add_raw_template("t", "{{ born | humandate }}")
884 .unwrap();
885 let mut ctx = Context::new();
886 ctx.insert("born", "1855-10-30");
887 assert_eq!(tera.render("t", &ctx).unwrap(), "October 30, 1855");
888 }
889
890 fn render_page(
901 frontmatter: SimpleMetadata,
902 extra_context: HashMap<String, serde_json::Value>,
903 ) -> String {
904 let mut context = HashMap::from([("sidebar_style".to_string(), json!("auto"))]);
905 context.extend(extra_context);
906
907 let tmp = tempfile::tempdir().unwrap();
908 let templates = Templates::new(tmp.path(), None).expect("templates should build");
909 templates
910 .render_markdown("<p>body</p>", frontmatter, context)
911 .expect("page should render")
912 }
913
914 fn js_value<'a>(html: &'a str, prefix: &str) -> &'a str {
921 html.lines()
922 .find_map(|line| line.trim().strip_prefix(prefix))
923 .unwrap_or_else(|| panic!("no line starting with {prefix:?} in:\n{html}"))
924 .trim_end()
925 .trim_end_matches([',', ';'])
926 }
927
928 fn parse_js_value(html: &str, prefix: &str) -> serde_json::Value {
931 let raw = js_value(html, prefix);
932 serde_json::from_str(raw)
933 .unwrap_or_else(|e| panic!("{prefix:?} payload is not parseable ({e}): {raw}"))
934 }
935
936 #[test]
937 fn test_escape_json_for_script_neutralizes_markup_characters() {
938 let escaped = escape_json_for_script(r#"{"title":"a </script> & <b>"}"#);
939 assert!(
940 !escaped.contains("</script>"),
941 "script terminator must not survive: {escaped}"
942 );
943 assert!(
944 escaped.contains("\\u003c"),
945 "`<` must be escaped: {escaped}"
946 );
947 assert!(
948 escaped.contains("\\u003e"),
949 "`>` must be escaped: {escaped}"
950 );
951 assert!(
952 escaped.contains("\\u0026"),
953 "`&` must be escaped: {escaped}"
954 );
955 }
956
957 #[test]
958 fn test_escape_json_for_script_preserves_decoded_value() {
959 let original = json!({"title": "a </script> & <b> \\ \" done"});
962 let escaped = escape_json_for_script(&serde_json::to_string(&original).unwrap());
963 let round_tripped: serde_json::Value = serde_json::from_str(&escaped).unwrap();
964 assert_eq!(round_tripped, original);
965 }
966
967 proptest::proptest! {
968 #[test]
972 fn prop_escape_json_for_script_is_inert_and_lossless(
973 key in "\\PC*",
974 value in "\\PC*",
975 ) {
976 let original = json!({ key: value });
977 let escaped = escape_json_for_script(&serde_json::to_string(&original).unwrap());
978
979 proptest::prop_assert!(
980 !escaped.contains(['<', '>', '&']),
981 "escaped payload must contain no markup characters: {escaped}"
982 );
983 let round_tripped: serde_json::Value = serde_json::from_str(&escaped).unwrap();
984 proptest::prop_assert_eq!(round_tripped, original);
985 }
986 }
987
988 #[test]
992 fn test_frontmatter_json_cannot_break_out_of_script_block() {
993 let frontmatter = SimpleMetadata::from([
994 (
995 "title".to_string(),
996 json!("Avoiding </script> in templates"),
997 ),
998 ("note".to_string(), json!("<img src=x onerror=alert(1)>")),
999 ]);
1000 let html = render_page(frontmatter, HashMap::new());
1001
1002 let payload = js_value(&html, "window.frontmatter = ");
1003 assert!(
1004 !payload.contains("</script>"),
1005 "frontmatter payload must not contain a raw script terminator: {payload}"
1006 );
1007 assert!(
1008 payload.contains("\\u003c/script\\u003e"),
1009 "expected the escaped terminator in: {payload}"
1010 );
1011 assert!(
1012 !payload.contains("<img"),
1013 "raw markup must not survive into the script: {payload}"
1014 );
1015
1016 let parsed = parse_js_value(&html, "window.frontmatter = ");
1018 assert_eq!(parsed["title"], json!("Avoiding </script> in templates"));
1019 assert_eq!(parsed["note"], json!("<img src=x onerror=alert(1)>"));
1020 }
1021
1022 #[test]
1026 fn test_title_is_html_escaped_exactly_once() {
1027 let html = render_page(
1028 SimpleMetadata::from([("title".to_string(), json!("Tips & Tricks"))]),
1029 HashMap::new(),
1030 );
1031 assert!(
1032 html.contains("<title>Tips & Tricks</title>"),
1033 "title should be escaped exactly once, got:\n{html}"
1034 );
1035 assert!(
1036 !html.contains("&amp;"),
1037 "no value should be double-escaped, got:\n{html}"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_title_markup_is_not_emitted_live() {
1043 let html = render_page(
1044 SimpleMetadata::from([(
1045 "title".to_string(),
1046 json!("</title><img src=x onerror=alert(1)>"),
1047 )]),
1048 HashMap::new(),
1049 );
1050 assert!(
1051 !html.contains("<img src=x"),
1052 "a frontmatter title must never produce live markup, got:\n{html}"
1053 );
1054 assert!(
1055 html.contains("<img src=x onerror=alert(1)>"),
1056 "title should render as escaped text, got:\n{html}"
1057 );
1058 }
1059
1060 #[test]
1064 fn test_file_path_and_sibling_urls_are_json_serialized() {
1065 let file_path = r#"docs/He said "hi" \ again.md"#;
1066 let extra = HashMap::from([
1067 ("file_path".to_string(), json!(file_path)),
1068 (
1069 "prev_page".to_string(),
1070 json!({"url": r#"/a"b\c/"#, "title": r#"A "quoted" & <angled> title"#}),
1071 ),
1072 (
1073 "next_page".to_string(),
1074 json!({"url": "/plain/", "title": "Plain"}),
1075 ),
1076 ]);
1077 let html = render_page(SimpleMetadata::new(), extra);
1078
1079 assert_eq!(parse_js_value(&html, "filePath: "), json!(file_path));
1080
1081 let prev = parse_js_value(&html, "prevPage: ");
1082 assert_eq!(prev["url"], json!(r#"/a"b\c/"#));
1083 assert_eq!(prev["title"], json!(r#"A "quoted" & <angled> title"#));
1084
1085 let next = parse_js_value(&html, "nextPage: ");
1086 assert_eq!(next["url"], json!("/plain/"));
1087 assert_eq!(next["title"], json!("Plain"));
1088 }
1089
1090 #[test]
1094 fn test_headings_are_json_serialized() {
1095 let extra = HashMap::from([(
1096 "headings".to_string(),
1097 json!([
1098 {"level": 2, "id": "q-and-a", "text": r#"Q & A: "the ' quote" </script>"#},
1099 {"level": 3, "id": "plain", "text": "Plain"},
1100 ]),
1101 )]);
1102 let html = render_page(SimpleMetadata::new(), extra);
1103
1104 let headings = parse_js_value(&html, "window.headings = ");
1105 assert_eq!(headings[0]["level"], json!(2));
1106 assert_eq!(headings[0]["id"], json!("q-and-a"));
1107 assert_eq!(
1108 headings[0]["text"],
1109 json!(r#"Q & A: "the ' quote" </script>"#),
1110 "heading text must round-trip verbatim, not HTML-entity encoded"
1111 );
1112 assert_eq!(headings[1]["text"], json!("Plain"));
1113 assert!(
1114 !js_value(&html, "window.headings = ").contains("</script>"),
1115 "heading text must not terminate the script block"
1116 );
1117 }
1118
1119 #[test]
1122 fn test_script_payload_defaults_without_extra_context() {
1123 let html = render_page(SimpleMetadata::new(), HashMap::new());
1124 assert_eq!(parse_js_value(&html, "window.headings = "), json!([]));
1125 assert_eq!(parse_js_value(&html, "filePath: "), json!(""));
1126 assert!(!html.contains("prevPage:"));
1127 assert!(!html.contains("nextPage:"));
1128 assert!(html.contains("fleschReadingEase: null"));
1130 assert!(html.contains("wordCount: 0"));
1131 }
1132
1133 #[test]
1141 fn test_malformed_user_template_falls_back_to_defaults() {
1142 let tmp = tempfile::tempdir().unwrap();
1143 let mbr_dir = tmp.path().join(".mbr");
1144 std::fs::create_dir_all(&mbr_dir).unwrap();
1145
1146 std::fs::write(mbr_dir.join("_nav.html"), "<nav>ZZ_CUSTOM_NAV</nav>").unwrap();
1148 std::fs::write(
1150 mbr_dir.join("index.html"),
1151 "{% if broken %}<p>ZZ_BROKEN</p>",
1152 )
1153 .unwrap();
1154
1155 let templates = Templates::new(tmp.path(), None)
1156 .expect("a malformed user template must not fail construction");
1157 let html = templates
1158 .render_markdown(
1159 "<p>body</p>",
1160 SimpleMetadata::new(),
1161 HashMap::from([("sidebar_style".to_string(), json!("auto"))]),
1162 )
1163 .expect("index.html should render from the built-in default");
1164
1165 assert!(
1166 html.contains("ZZ_CUSTOM_NAV"),
1167 "the valid override must survive its malformed sibling, got:\n{html}"
1168 );
1169 assert!(
1170 html.contains("<mbr-nav></mbr-nav>"),
1171 "the built-in index.html should be used for the malformed override, got:\n{html}"
1172 );
1173 assert!(
1174 !html.contains("ZZ_BROKEN"),
1175 "the malformed template must not be loaded, got:\n{html}"
1176 );
1177 }
1178
1179 #[test]
1182 fn test_per_file_loading_resolves_inheritance_regardless_of_order() {
1183 let tmp = tempfile::tempdir().unwrap();
1184 let dir = tmp.path();
1185 std::fs::write(dir.join("child.html"), "{% extends \"zz_base.html\" %}").unwrap();
1187 std::fs::write(dir.join("zz_base.html"), "ZZ_BASE_OUTPUT").unwrap();
1188 std::fs::write(dir.join("busted.html"), "{% if never_closed %}oops").unwrap();
1189
1190 let tera = load_tera_per_file(dir);
1191
1192 assert_eq!(
1193 tera.render("child.html", &Context::new()).unwrap(),
1194 "ZZ_BASE_OUTPUT"
1195 );
1196 assert!(
1197 tera.get_template("busted.html").is_err(),
1198 "the malformed template should be skipped"
1199 );
1200 }
1201
1202 fn body_tag(html: &str) -> &str {
1208 html.lines()
1209 .map(str::trim)
1210 .find(|line| line.starts_with("<body"))
1211 .unwrap_or_else(|| panic!("no <body> tag in:\n{html}"))
1212 }
1213
1214 #[test]
1215 fn test_rendered_body_class_from_type_alone() {
1216 let html = render_page(
1217 SimpleMetadata::from([("type".to_string(), json!("slides"))]),
1218 HashMap::new(),
1219 );
1220 assert_eq!(body_tag(&html), r#"<body class="slides">"#);
1221 }
1222
1223 #[test]
1224 fn test_rendered_body_class_from_type_and_style() {
1225 let html = render_page(
1226 SimpleMetadata::from([
1227 ("type".to_string(), json!("Meeting Notes")),
1228 ("style".to_string(), json!(["wide", "dark"])),
1229 ]),
1230 HashMap::new(),
1231 );
1232 assert_eq!(body_tag(&html), r#"<body class="meeting-notes wide dark">"#);
1233 }
1234
1235 #[test]
1239 fn test_rendered_body_class_from_style_array_has_no_brackets() {
1240 let html = render_page(
1241 SimpleMetadata::from([("style".to_string(), json!(["slides", "other"]))]),
1242 HashMap::new(),
1243 );
1244 assert_eq!(body_tag(&html), r#"<body class="slides other">"#);
1245 }
1246
1247 #[test]
1248 fn test_rendered_body_has_no_class_without_type_or_style() {
1249 let html = render_page(SimpleMetadata::new(), HashMap::new());
1250 assert_eq!(body_tag(&html), "<body>");
1251 }
1252
1253 #[test]
1258 fn test_type_context_variable_is_passed_through_unmodified() {
1259 let html = render_page(
1260 SimpleMetadata::from([
1261 ("type".to_string(), json!("person")),
1262 ("title".to_string(), json!("Ada Lovelace")),
1263 ("born".to_string(), json!("1815-12-10")),
1264 ]),
1265 HashMap::new(),
1266 );
1267
1268 assert!(
1269 html.contains("<mbr-genealogy></mbr-genealogy>"),
1270 "genealogy element should render for `type: person`, got:\n{html}"
1271 );
1272 assert_eq!(body_tag(&html), r#"<body class="person">"#);
1274 assert_eq!(
1275 parse_js_value(&html, "window.frontmatter = ")["type"],
1276 json!("person"),
1277 "frontmatter payload should carry the authored type"
1278 );
1279 }
1280
1281 fn render_parsed(frontmatter: &str) -> String {
1284 let tmp = tempfile::tempdir().unwrap();
1285 let file = tmp.path().join("note.md");
1286 std::fs::write(&file, format!("---\n{frontmatter}---\nBody.\n")).unwrap();
1287 let parsed = crate::markdown::parse(&file).unwrap();
1288 render_page(parsed.frontmatter, HashMap::new())
1289 }
1290
1291 #[test]
1295 fn test_type_with_surrounding_whitespace_gates_like_the_trimmed_value() {
1296 let html = render_parsed("type: \" person\"\n");
1297 assert!(html.contains("<mbr-genealogy></mbr-genealogy>"), "{html}");
1298 assert!(
1299 html.contains("components/mbr-genealogy.min.js"),
1300 "prefetch: {html}"
1301 );
1302 assert_eq!(body_tag(&html), r#"<body class="person">"#);
1303 assert_eq!(
1304 parse_js_value(&html, "window.frontmatter = ")["type"],
1305 json!("person")
1306 );
1307
1308 let html = render_parsed("type: \"Meeting Notes \"\n");
1309 assert_eq!(body_tag(&html), r#"<body class="meeting-notes">"#);
1310 assert_eq!(
1311 parse_js_value(&html, "window.frontmatter = ")["type"],
1312 json!("Meeting Notes")
1313 );
1314
1315 let html = render_parsed("type: \"flashcard \"\n");
1316 assert_eq!(body_tag(&html), r#"<body class="flashcard">"#);
1317
1318 let html = render_parsed("type: \" \"\n");
1319 assert_eq!(body_tag(&html), "<body>");
1320 assert!(
1321 parse_js_value(&html, "window.frontmatter = ")
1322 .get("type")
1323 .is_none()
1324 );
1325 }
1326
1327 fn render_contact(yaml: &str, extra: &[(&str, serde_json::Value)]) -> String {
1334 render_contact_with(None, yaml, extra)
1335 }
1336
1337 fn render_contact_with(
1338 user_index: Option<&str>,
1339 yaml: &str,
1340 extra: &[(&str, serde_json::Value)],
1341 ) -> String {
1342 let doc = yaml_rust2::YamlLoader::load_from_str(yaml)
1343 .unwrap()
1344 .remove(0);
1345 let mut frontmatter = SimpleMetadata::new();
1346 for key in ["type", "title"] {
1347 if let Some(v) = doc[key].as_str() {
1348 frontmatter.insert(key.to_string(), json!(v));
1349 }
1350 }
1351 let mut context: HashMap<String, serde_json::Value> = HashMap::from([
1352 ("sidebar_style".to_string(), json!("auto")),
1353 ("has_h1".to_string(), json!(false)),
1354 ]);
1355 context.extend(extra.iter().map(|(k, v)| (k.to_string(), v.clone())));
1356 let backlinks = context
1357 .remove("contact_backlinks")
1358 .and_then(|v| v.as_u64())
1359 .map(|n| n as usize);
1360 crate::page_context::insert_contact(
1361 &mut context,
1362 crate::contact::Contact::from_yaml(&doc),
1363 &[],
1364 backlinks,
1365 &crate::page_context::UrlMode::Absolute,
1366 );
1367
1368 let tmp = tempfile::tempdir().unwrap();
1369 if let Some(index) = user_index {
1370 std::fs::create_dir_all(tmp.path().join(".mbr")).unwrap();
1371 std::fs::write(tmp.path().join(".mbr/index.html"), index).unwrap();
1372 }
1373 Templates::new(tmp.path(), None)
1374 .unwrap()
1375 .render_markdown("<p>body</p>", frontmatter, context)
1376 .expect("page should render")
1377 }
1378
1379 const JANE: &str = concat!(
1380 "type: person\n",
1381 "title: Jane Doe\n",
1382 "job_title: VP Marketing\n",
1383 "department: Marketing\n",
1384 "company: Acme\n",
1385 "gender: Female\n",
1386 "aliases: [Mare, {maiden_name: Mary Smith}]\n",
1387 "phones:\n - mobile: \"+1 (303) 555-0100 ext. 12\"\n",
1388 "emails: {work: jane@abc.example}\n",
1389 "urls:\n homepage: https://jane.example\n evil: \"javascript:alert(1)\"\n",
1390 "social: {twitter: \"@jdoe\"}\n",
1391 "addresses: {home: {street: 52 Rue Bonsergent, city: Paris, country: France}}\n",
1392 "born: 1927-03-19\n",
1393 "born_place: Boulder, CO\n",
1394 "dates: {anniversary: 06-10}\n",
1395 );
1396
1397 #[test]
1398 fn test_contact_card_is_the_page_header() {
1399 let html = render_contact(JANE, &[]);
1400 assert!(html.contains(
1401 r#"<header class="mbr-contact-card" data-kind="person" data-gender="female">"#
1402 ));
1403 assert_eq!(html.matches("<h1").count(), 1, "one headline:\n{html}");
1404 assert!(html.contains(r#"<h1 class="mbr-contact-card-name">Jane Doe</h1>"#));
1405 assert!(html.contains("<span>VP Marketing</span><span>Marketing</span><span>Acme</span>"));
1406 assert!(html.contains("<span>aka Mare</span><span>née Mary Smith</span>"));
1407 assert!(html.contains("mbr-contact-card-avatar-default"));
1409 assert!(html.find("mbr-contact-card").unwrap() < html.find("<p>body</p>").unwrap());
1411 }
1412
1413 #[test]
1414 fn test_contact_card_links_are_allowlisted() {
1415 let html = render_contact(JANE, &[]);
1416 assert!(
1417 html.contains(r#"<a href="tel:+13035550100;ext=12">+1 (303) 555-0100 ext. 12</a>"#),
1418 "{html}"
1419 );
1420 assert!(html.contains(r#"<a href="mailto:jane@abc.example">jane@abc.example</a>"#));
1421 assert!(
1423 html.contains(r#"rel="noopener noreferrer">https://jane.example</a>"#),
1424 "{html}"
1425 );
1426 assert!(
1427 !html.contains(r#"href="javascript"#),
1428 "javascript: must never be a link"
1429 );
1430 assert!(html.contains("<span>javascript:alert(1)</span>"));
1431 assert!(html.contains("<span>@jdoe</span>"), "a handle stays text");
1432 }
1433
1434 #[test]
1435 fn test_contact_card_address_and_dates() {
1436 let html = render_contact(JANE, &[]);
1437 assert!(html.contains("<address>52 Rue Bonsergent<br />Paris<br />France</address>"));
1438 assert!(html.contains(r#"<time datetime="1927-03-19">March 19, 1927</time><span class="mbr-contact-card-place">Boulder, CO</span>"#), "{html}");
1439 assert!(html.contains(r#"<time datetime="06-10">June 10</time>"#));
1440 }
1441
1442 #[test]
1443 fn test_contact_card_yields_headline_to_an_authored_h1() {
1444 let html = render_contact(JANE, &[("has_h1", json!(true))]);
1445 assert!(!html.contains("mbr-contact-card-name"), "{html}");
1446 assert!(html.contains("mbr-contact-card"));
1447 }
1448
1449 #[test]
1450 fn test_contact_card_backlink_chip_states() {
1451 let known = render_contact(JANE, &[("contact_backlinks", json!(3))]);
1452 assert!(known.contains(r#"<mbr-contact-backlinks count="3"><button type="button" class="mbr-contact-card-chip">Linked from 3 notes</button></mbr-contact-backlinks>"#));
1453 let one = render_contact(JANE, &[("contact_backlinks", json!(1))]);
1454 assert!(one.contains("Linked from 1 note</button>"));
1455 let none = render_contact(JANE, &[("contact_backlinks", json!(0))]);
1456 assert!(!none.contains("mbr-contact-backlinks"));
1457 let unknown = render_contact(JANE, &[]);
1458 assert!(unknown.contains("<mbr-contact-backlinks></mbr-contact-backlinks>"));
1459 }
1460
1461 #[test]
1462 fn test_contact_card_escapes_frontmatter() {
1463 let html = render_contact(
1464 "type: person\ntitle: \"<script>x()</script>\"\njob_title: \"<b>boss</b>\"\n",
1465 &[],
1466 );
1467 assert!(!html.contains("<script>x()"));
1468 assert!(!html.contains("<b>boss</b>"));
1469 }
1470
1471 #[test]
1472 fn test_contact_card_organization_and_name_parts() {
1473 let org = render_contact("type: organization\ncompany: Acme Corp\n", &[]);
1474 assert!(org.contains(r#"data-kind="organization""#));
1475 assert!(org.contains(r#"<h1 class="mbr-contact-card-name">Acme Corp</h1>"#));
1476 assert!(org.contains("<mbr-genealogy></mbr-genealogy>"));
1477
1478 let parts = render_contact("type: person\nfirst_name: Ada\nlast_name: Lovelace\n", &[]);
1479 assert!(parts.contains(r#"<h1 class="mbr-contact-card-name">Ada Lovelace</h1>"#));
1480 }
1481
1482 #[test]
1483 fn test_non_contact_pages_get_no_card() {
1484 let html = render_contact("type: event\ntitle: Party\n", &[]);
1485 assert!(!html.contains("mbr-contact-card"));
1486 assert!(html.contains("<h1>Party</h1>"));
1487 }
1488
1489 #[test]
1492 fn test_legacy_index_including_person_infobox_gets_the_card_once() {
1493 let legacy = concat!(
1494 "<html><body><main>",
1495 "{% if title and not has_h1 %}<h1>{{ title }}</h1>{% endif %}",
1496 "{% include \"_person_infobox.html\" %}{{ markdown | safe }}",
1497 "</main></body></html>",
1498 );
1499 let html = render_contact_with(Some(legacy), JANE, &[]);
1500 assert_eq!(html.matches("<h1").count(), 1, "{html}");
1501 assert!(html.contains("<h1>Jane Doe</h1>"));
1502 assert!(html.contains("mbr-contact-card"));
1503 assert!(html.contains("tel:+13035550100"));
1504 }
1505}