1#[cfg(feature = "templates")]
11use anyhow::{Context, Result};
12#[cfg(feature = "templates")]
13use std::{collections::HashMap, path::PathBuf};
14
15#[cfg(feature = "templates")]
17#[derive(Debug, Clone)]
18pub struct TemplateConfig {
19 pub template_dir: PathBuf,
21 pub globals: HashMap<String, serde_json::Value>,
23 pub autoescape: bool,
25}
26
27#[cfg(feature = "templates")]
28impl Default for TemplateConfig {
29 fn default() -> Self {
30 Self {
31 template_dir: PathBuf::from("templates/tera"),
32 globals: HashMap::new(),
33 autoescape: true,
34 }
35 }
36}
37
38#[cfg(feature = "templates")]
40#[derive(Debug)]
41pub struct TemplateEngine {
42 env: minijinja::Environment<'static>,
43 config: TemplateConfig,
44}
45
46#[cfg(feature = "templates")]
47impl TemplateEngine {
48 pub fn init(config: TemplateConfig) -> Result<Option<Self>> {
69 if !config.template_dir.exists() {
70 return Ok(None);
71 }
72
73 let mut env = minijinja::Environment::new();
74 env.set_loader(minijinja::path_loader(&config.template_dir));
75
76 if !config.autoescape {
77 env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
78 }
79
80 env.add_filter("reading_time", reading_time_filter);
82 env.add_filter("slugify", slugify_filter);
83
84 Ok(Some(Self { env, config }))
85 }
86
87 pub fn render_page(
128 &self,
129 template_name: &str,
130 page_content: &str,
131 frontmatter: &HashMap<String, serde_json::Value>,
132 site_globals: &HashMap<String, serde_json::Value>,
133 ) -> Result<String> {
134 let resolved_lang = crate::core_group::lang::resolve_render_lang(
140 frontmatter,
141 site_globals
142 .get("language")
143 .and_then(serde_json::Value::as_str),
144 );
145
146 let mut page: serde_json::Map<String, serde_json::Value> = frontmatter
148 .iter()
149 .map(|(k, v)| (k.clone(), v.clone()))
150 .collect();
151 let _ = page.insert(
152 "content".to_string(),
153 serde_json::Value::String(page_content.to_string()),
154 );
155 let _ = page.insert(
156 "language".to_string(),
157 serde_json::Value::String(resolved_lang.clone()),
158 );
159 let direction = crate::core_group::lang::text_direction(&resolved_lang);
164 let _ = page.insert(
165 "direction".to_string(),
166 serde_json::Value::String(direction.to_string()),
167 );
168
169 let mut site: serde_json::Map<String, serde_json::Value> = site_globals
175 .iter()
176 .map(|(k, v)| (k.clone(), v.clone()))
177 .collect();
178 let _ = site.insert(
179 "language".to_string(),
180 serde_json::Value::String(resolved_lang),
181 );
182 let _ = site.insert(
183 "direction".to_string(),
184 serde_json::Value::String(direction.to_string()),
185 );
186
187 let mut ctx = serde_json::Map::new();
188 let _ = ctx.insert("page".to_string(), serde_json::Value::Object(page));
189 let _ = ctx.insert("site".to_string(), serde_json::Value::Object(site));
190
191 for (k, v) in &self.config.globals {
193 let _ = ctx.insert(k.clone(), v.clone());
194 }
195
196 let _ = ctx.insert(
202 "direction".to_string(),
203 serde_json::Value::String(direction.to_string()),
204 );
205
206 let (tmpl_name, tmpl) =
211 if let Ok(t) = self.env.get_template(template_name) {
212 (template_name, t)
213 } else if let Ok(t) = self.env.get_template("page.html") {
214 ("page.html", t)
215 } else {
216 return Ok(page_content.to_string());
218 };
219
220 tmpl.render(serde_json::Value::Object(ctx))
221 .with_context(|| format!("Failed to render template '{tmpl_name}'"))
222 }
223
224 #[must_use]
249 pub fn has_template(&self, name: &str) -> bool {
250 self.env.get_template(name).is_ok()
251 }
252
253 #[must_use]
267 pub fn site_globals_from_config(
268 config: &crate::cmd::SsgConfig,
269 ) -> HashMap<String, serde_json::Value> {
270 let mut globals = HashMap::new();
275 for (key, value) in [
276 ("name", &config.site_name),
277 ("title", &config.site_title),
278 ("description", &config.site_description),
279 ("base_url", &config.base_url),
280 ("language", &config.language),
281 ] {
282 if !value.is_empty() {
283 let _ = globals.insert(
284 key.to_string(),
285 serde_json::Value::String(value.clone()),
286 );
287 }
288 }
289 globals
290 }
291
292 #[must_use]
311 pub fn load_data_files(
312 content_dir: &std::path::Path,
313 ) -> HashMap<String, serde_json::Value> {
314 let data_dir = content_dir.parent().unwrap_or(content_dir).join("data");
315 let mut data = HashMap::new();
316
317 if !data_dir.exists() {
318 return data;
319 }
320
321 let Ok(entries) = std::fs::read_dir(&data_dir) else {
322 return data;
323 };
324
325 for entry in entries.flatten() {
326 let path = entry.path();
327 if !path.is_file() {
328 continue;
329 }
330
331 let stem = path
332 .file_stem()
333 .unwrap_or_default()
334 .to_string_lossy()
335 .to_string();
336 let ext = path
337 .extension()
338 .unwrap_or_default()
339 .to_string_lossy()
340 .to_lowercase();
341
342 let Ok(content) = std::fs::read_to_string(&path) else {
343 continue;
344 };
345
346 let value: Option<serde_json::Value> = match ext.as_str() {
347 "toml" => match toml::from_str::<serde_json::Value>(&content) {
348 Ok(v) => Some(v),
349 Err(e) => {
350 log::warn!(
351 "Failed to parse data file {}: {e}",
352 path.display()
353 );
354 None
355 }
356 },
357 "json" => match serde_json::from_str(&content) {
358 Ok(v) => Some(v),
359 Err(e) => {
360 log::warn!(
361 "Failed to parse data file {}: {e}",
362 path.display()
363 );
364 None
365 }
366 },
367 "yml" | "yaml" => {
368 match noyalib::from_str::<serde_json::Value>(&content) {
369 Ok(v) => Some(v),
370 Err(e) => {
371 log::warn!(
372 "Failed to parse data file {}: {e}",
373 path.display()
374 );
375 None
376 }
377 }
378 }
379 _ => None,
380 };
381
382 if let Some(val) = value {
383 let _ = data.insert(stem, val);
384 }
385 }
386
387 data
388 }
389}
390
391#[cfg(feature = "templates")]
396fn reading_time_filter(value: String) -> String {
397 let word_count = value.split_whitespace().count();
398 let minutes = (word_count / 200).max(1);
399 format!("{minutes} min read")
400}
401
402#[cfg(feature = "templates")]
406fn slugify_filter(value: String) -> String {
407 value
408 .to_lowercase()
409 .chars()
410 .map(|c| if c.is_alphanumeric() { c } else { '-' })
411 .collect::<String>()
412 .split('-')
413 .filter(|s| !s.is_empty())
414 .collect::<Vec<_>>()
415 .join("-")
416}
417
418#[cfg(all(test, feature = "templates"))]
419mod tests {
420 use super::*;
421 use std::fs;
422 use std::path::Path;
423 use tempfile::tempdir;
424
425 fn setup_templates(dir: &Path) {
426 crate::test_support::init_logger();
427 let tera_dir = dir.join("tera");
428 fs::create_dir_all(&tera_dir).unwrap();
429
430 fs::write(
431 tera_dir.join("base.html"),
432 r#"<!DOCTYPE html>
433<html lang="{{ site.language | default("en") }}">
434<head><title>{% block title %}{{ page.title | default("Untitled") }}{% endblock %}</title>
435{% block head_extra %}{% endblock %}
436</head>
437<body>
438<main>{% block content %}{% endblock %}</main>
439<footer>{% block footer %}<p>© {{ site.name | default("") }}</p>{% endblock %}</footer>
440</body>
441</html>"#,
442 )
443 .unwrap();
444
445 fs::write(
446 tera_dir.join("page.html"),
447 r#"{% extends "base.html" %}
448{% block content %}{{ page.content | safe }}{% endblock %}"#,
449 )
450 .unwrap();
451
452 fs::write(
453 tera_dir.join("post.html"),
454 r#"{% extends "base.html" %}
455{% block content %}
456<article>
457<h1>{{ page.title | default("") }}</h1>
458<time>{{ page.date | default("") }}</time>
459<p>{{ page.content | reading_time }}</p>
460{{ page.content | safe }}
461</article>
462{% endblock %}"#,
463 )
464 .unwrap();
465 }
466
467 #[test]
468 fn test_init_missing_dir() {
469 let config = TemplateConfig {
470 template_dir: PathBuf::from("/nonexistent/path"),
471 ..Default::default()
472 };
473 let result = TemplateEngine::init(config).unwrap();
474 assert!(result.is_none());
475 }
476
477 #[test]
478 fn test_init_and_render_page() {
479 let dir = tempdir().unwrap();
480 setup_templates(dir.path());
481
482 let config = TemplateConfig {
483 template_dir: dir.path().join("tera"),
484 ..Default::default()
485 };
486 let engine = TemplateEngine::init(config).unwrap().unwrap();
487
488 let mut fm = HashMap::new();
489 let _ = fm.insert(
490 "title".to_string(),
491 serde_json::Value::String("Hello".to_string()),
492 );
493
494 let mut site = HashMap::new();
495 let _ = site.insert(
496 "name".to_string(),
497 serde_json::Value::String("My Site".to_string()),
498 );
499 let _ = site.insert(
500 "language".to_string(),
501 serde_json::Value::String("en-GB".to_string()),
502 );
503
504 let result = engine
505 .render_page("page.html", "<p>Body</p>", &fm, &site)
506 .unwrap();
507
508 assert!(result.contains("Hello"));
509 assert!(result.contains("<p>Body</p>"));
510 assert!(result.contains("My Site"));
511 assert!(result.contains("en-GB"));
512 }
513
514 fn engine_with_default_templates(dir: &Path) -> TemplateEngine {
517 setup_templates(dir);
518 let config = TemplateConfig {
519 template_dir: dir.join("tera"),
520 ..Default::default()
521 };
522 TemplateEngine::init(config).unwrap().unwrap()
523 }
524
525 fn site_with_language(lang: &str) -> HashMap<String, serde_json::Value> {
526 let mut site = HashMap::new();
527 let _ = site.insert(
528 "language".to_string(),
529 serde_json::Value::String(lang.to_string()),
530 );
531 site
532 }
533
534 #[test]
535 fn frontmatter_language_wins_over_site_language_in_html_lang() {
536 let dir = tempdir().unwrap();
540 let engine = engine_with_default_templates(dir.path());
541
542 let mut fm = HashMap::new();
543 let _ = fm.insert(
544 "language".to_string(),
545 serde_json::Value::String("hi".to_string()),
546 );
547
548 let result = engine
549 .render_page(
550 "page.html",
551 "<p>B</p>",
552 &fm,
553 &site_with_language("en-GB"),
554 )
555 .unwrap();
556 assert!(
557 result.contains(r#"<html lang="hi">"#),
558 "front-matter language must win: {result}"
559 );
560 assert!(!result.contains(r#"lang="en-GB""#));
561 }
562
563 #[test]
564 fn frontmatter_hreflang_used_when_language_absent() {
565 let dir = tempdir().unwrap();
566 let engine = engine_with_default_templates(dir.path());
567
568 let mut fm = HashMap::new();
569 let _ = fm.insert(
570 "hreflang".to_string(),
571 serde_json::Value::String("fr_fr".to_string()),
572 );
573
574 let result = engine
575 .render_page(
576 "page.html",
577 "<p>B</p>",
578 &fm,
579 &site_with_language("en"),
580 )
581 .unwrap();
582 assert!(
584 result.contains(r#"<html lang="fr-FR">"#),
585 "hreflang should be used and normalised: {result}"
586 );
587 }
588
589 #[test]
590 fn site_language_used_when_page_has_no_lang_signal() {
591 let dir = tempdir().unwrap();
592 let engine = engine_with_default_templates(dir.path());
593
594 let result = engine
595 .render_page(
596 "page.html",
597 "<p>B</p>",
598 &HashMap::new(),
599 &site_with_language("en-GB"),
600 )
601 .unwrap();
602 assert!(result.contains(r#"<html lang="en-GB">"#));
603 }
604
605 #[test]
606 fn en_fallback_when_no_language_anywhere() {
607 let dir = tempdir().unwrap();
608 let engine = engine_with_default_templates(dir.path());
609
610 let result = engine
611 .render_page(
612 "page.html",
613 "<p>B</p>",
614 &HashMap::new(),
615 &HashMap::new(),
616 )
617 .unwrap();
618 assert!(result.contains(r#"<html lang="en">"#));
619 }
620
621 #[test]
622 fn test_render_post_with_reading_time() {
623 let dir = tempdir().unwrap();
624 setup_templates(dir.path());
625
626 let config = TemplateConfig {
627 template_dir: dir.path().join("tera"),
628 ..Default::default()
629 };
630 let engine = TemplateEngine::init(config).unwrap().unwrap();
631
632 let content = "word ".repeat(600); let mut fm = HashMap::new();
634 let _ = fm.insert(
635 "title".to_string(),
636 serde_json::Value::String("Post".to_string()),
637 );
638 let _ = fm.insert(
639 "date".to_string(),
640 serde_json::Value::String("2026-01-01".to_string()),
641 );
642
643 let site = HashMap::new();
644 let result = engine
645 .render_page("post.html", &content, &fm, &site)
646 .unwrap();
647
648 assert!(result.contains("3 min read"));
649 assert!(result.contains("<article>"));
650 }
651
652 #[test]
653 fn test_fallback_to_page_html() {
654 let dir = tempdir().unwrap();
655 setup_templates(dir.path());
656
657 let config = TemplateConfig {
658 template_dir: dir.path().join("tera"),
659 ..Default::default()
660 };
661 let engine = TemplateEngine::init(config).unwrap().unwrap();
662
663 let fm = HashMap::new();
664 let site = HashMap::new();
665 let result = engine
666 .render_page("nonexistent.html", "<p>fallback</p>", &fm, &site)
667 .unwrap();
668
669 assert!(result.contains("<p>fallback</p>"));
670 }
671
672 #[test]
673 fn test_reading_time_filter_direct() {
674 let text = "word ".repeat(400);
675 let result = reading_time_filter(text);
676 assert_eq!(result, "2 min read");
677 }
678
679 #[test]
680 fn test_slugify_filter() {
681 assert_eq!(slugify_filter("Hello World!".to_string()), "hello-world");
682 assert_eq!(slugify_filter("Rust & Web".to_string()), "rust-web");
683 }
684
685 #[test]
690 fn load_data_files_missing_data_dir_returns_empty_map() {
691 let dir = tempdir().unwrap();
692 let content = dir.path().join("content");
693 fs::create_dir_all(&content).unwrap();
694 let result = TemplateEngine::load_data_files(&content);
695 assert!(result.is_empty());
696 }
697
698 #[test]
699 fn load_data_files_parses_toml_and_json_and_yaml() {
700 let dir = tempdir().unwrap();
701 let content = dir.path().join("content");
702 fs::create_dir_all(&content).unwrap();
703 let data = dir.path().join("data");
704 fs::create_dir_all(&data).unwrap();
705
706 fs::write(data.join("site.toml"), r#"key = "toml-value""#).unwrap();
707 fs::write(data.join("nav.json"), r#"{"items": ["home", "about"]}"#)
708 .unwrap();
709 fs::write(data.join("conf.yml"), r#"{"yaml": "value"}"#).unwrap();
710 fs::write(data.join("ignored.txt"), "not parsed").unwrap();
711
712 let sub = data.join("sub");
713 fs::create_dir_all(&sub).unwrap();
714 fs::write(sub.join("inside.json"), "{}").unwrap();
715
716 let result = TemplateEngine::load_data_files(&content);
717 assert!(result.contains_key("site"));
718 assert!(result.contains_key("nav"));
719 assert!(result.contains_key("conf"));
720 assert!(!result.contains_key("ignored"));
721 assert!(!result.contains_key("sub"));
722 }
723
724 #[test]
725 fn load_data_files_skips_files_with_invalid_content() {
726 let dir = tempdir().unwrap();
727 let content = dir.path().join("content");
728 fs::create_dir_all(&content).unwrap();
729 let data = dir.path().join("data");
730 fs::create_dir_all(&data).unwrap();
731
732 fs::write(data.join("broken.toml"), "not valid toml [[[").unwrap();
733 fs::write(data.join("broken.json"), "{not valid").unwrap();
734 fs::write(data.join("good.toml"), r#"x = "y""#).unwrap();
735
736 let result = TemplateEngine::load_data_files(&content);
737 assert!(result.contains_key("good"));
738 assert!(!result.contains_key("broken"));
739 }
740
741 #[test]
742 fn load_data_files_skips_non_utf8_file() {
743 let dir = tempdir().unwrap();
746 let content = dir.path().join("content");
747 fs::create_dir_all(&content).unwrap();
748 let data = dir.path().join("data");
749 fs::create_dir_all(&data).unwrap();
750
751 fs::write(data.join("binary.toml"), [0xFF, 0xFE, 0x00, 0x01]).unwrap();
752 fs::write(data.join("ok.toml"), r#"k = "v""#).unwrap();
753
754 let result = TemplateEngine::load_data_files(&content);
755 assert!(result.contains_key("ok"));
756 assert!(!result.contains_key("binary"));
757 }
758
759 #[test]
760 fn load_data_files_skips_invalid_yaml() {
761 crate::test_support::init_logger();
764 let dir = tempdir().unwrap();
765 let content = dir.path().join("content");
766 fs::create_dir_all(&content).unwrap();
767 let data = dir.path().join("data");
768 fs::create_dir_all(&data).unwrap();
769
770 fs::write(data.join("broken.yml"), "key: [unclosed").unwrap();
771 fs::write(data.join("good.yaml"), "k: v").unwrap();
772
773 let result = TemplateEngine::load_data_files(&content);
774 assert!(result.contains_key("good"));
775 assert!(!result.contains_key("broken"));
776 }
777
778 #[test]
783 fn load_data_files_refuses_yaml_alias_amplification() {
784 crate::test_support::init_logger();
785 let dir = tempdir().unwrap();
786 let content = dir.path().join("content");
787 fs::create_dir_all(&content).unwrap();
788 let data = dir.path().join("data");
789 fs::create_dir_all(&data).unwrap();
790
791 let mut bomb = String::new();
792 for level in 0..4 {
793 let item = if level == 0 {
794 "x".to_string()
795 } else {
796 format!("*l{}", level - 1)
797 };
798 let items = vec![item; 30].join(", ");
799 bomb.push_str(&format!("l{level}: &l{level} [{items}]\n"));
800 }
801 fs::write(data.join("bomb.yml"), &bomb).unwrap();
802 fs::write(data.join("ok.yaml"), "base: &b [a, b]\ncopy: *b").unwrap();
803
804 let result = TemplateEngine::load_data_files(&content);
805 assert!(
806 !result.contains_key("bomb"),
807 "{}-byte data file was accepted",
808 bomb.len()
809 );
810 assert_eq!(result["ok"]["copy"], result["ok"]["base"]);
811 }
812
813 #[test]
814 fn load_data_files_ignores_unsupported_extensions() {
815 let dir = tempdir().unwrap();
816 let content = dir.path().join("content");
817 fs::create_dir_all(&content).unwrap();
818 let data = dir.path().join("data");
819 fs::create_dir_all(&data).unwrap();
820
821 fs::write(data.join("a.xml"), "<x/>").unwrap();
822 fs::write(data.join("b.csv"), "a,b").unwrap();
823 fs::write(data.join("c"), "no extension").unwrap();
824
825 let result = TemplateEngine::load_data_files(&content);
826 assert!(result.is_empty());
827 }
828
829 #[test]
834 fn render_page_injects_custom_globals_from_config() {
835 let dir = tempdir().unwrap();
836 setup_templates(dir.path());
837
838 fs::write(
840 dir.path().join("tera").join("branded.html"),
841 r"<p>{{ brand }}</p>",
842 )
843 .unwrap();
844
845 let config = TemplateConfig {
846 template_dir: dir.path().join("tera"),
847 globals: {
848 let mut g = HashMap::new();
849 let _ = g.insert(
850 "brand".to_string(),
851 serde_json::Value::String("Acme".to_string()),
852 );
853 g
854 },
855 ..Default::default()
856 };
857 let engine = TemplateEngine::init(config).unwrap().unwrap();
858
859 let result = engine
860 .render_page("branded.html", "", &HashMap::new(), &HashMap::new())
861 .unwrap();
862 assert!(result.contains("Acme"));
863 }
864
865 #[test]
866 fn render_page_no_matching_template_and_no_page_html_returns_content_as_is()
867 {
868 let dir = tempdir().unwrap();
869 let tera_dir = dir.path().join("tera");
870 fs::create_dir_all(&tera_dir).unwrap();
871 fs::write(
873 tera_dir.join("base.html"),
874 r"<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>",
875 )
876 .unwrap();
877
878 let config = TemplateConfig {
879 template_dir: tera_dir,
880 ..Default::default()
881 };
882 let engine = TemplateEngine::init(config).unwrap().unwrap();
883
884 let content = "<p>raw content</p>";
885 let result = engine
886 .render_page(
887 "nonexistent.html",
888 content,
889 &HashMap::new(),
890 &HashMap::new(),
891 )
892 .unwrap();
893 assert_eq!(result, content);
894 }
895
896 #[test]
897 fn init_with_autoescape_false() {
898 let dir = tempdir().unwrap();
899 setup_templates(dir.path());
900
901 let config = TemplateConfig {
902 template_dir: dir.path().join("tera"),
903 autoescape: false,
904 ..Default::default()
905 };
906 let engine = TemplateEngine::init(config).unwrap().unwrap();
907 let result = engine
908 .render_page(
909 "page.html",
910 "<p>x</p>",
911 &HashMap::new(),
912 &HashMap::new(),
913 )
914 .unwrap();
915 assert!(result.contains("<p>x</p>"));
916 }
917
918 #[test]
919 fn init_with_broken_template_errors_on_render() {
920 let dir = tempdir().unwrap();
921 let tera_dir = dir.path().join("tera");
922 fs::create_dir_all(&tera_dir).unwrap();
923 fs::write(tera_dir.join("broken.html"), "{% extends \"nonexistent_parent.html\" %}{% block x %}{% endblock %}").unwrap();
925
926 let config = TemplateConfig {
927 template_dir: tera_dir,
928 ..Default::default()
929 };
930 let engine = TemplateEngine::init(config).unwrap().unwrap();
932 let result = engine.render_page(
934 "broken.html",
935 "",
936 &HashMap::new(),
937 &HashMap::new(),
938 );
939 assert!(result.is_err());
940 }
941
942 #[test]
943 #[cfg(unix)]
944 fn load_data_files_unreadable_file_continues_silently() {
945 let dir = tempdir().unwrap();
946 let content = dir.path().join("content");
947 fs::create_dir_all(&content).unwrap();
948 let data = dir.path().join("data");
949 fs::create_dir_all(&data).unwrap();
950
951 fs::create_dir_all(data.join("not-really.toml")).unwrap();
952 fs::write(data.join("real.toml"), r#"k = "v""#).unwrap();
953
954 let result = TemplateEngine::load_data_files(&content);
955 assert!(result.contains_key("real"));
956 assert!(!result.contains_key("not-really"));
957 }
958
959 #[test]
960 fn load_data_files_data_dir_is_a_file_returns_empty() {
961 let dir = tempdir().unwrap();
962 let content = dir.path().join("content");
963 fs::create_dir_all(&content).unwrap();
964 let data = dir.path().join("data");
965 fs::write(&data, "I am a file, not a directory").unwrap();
966
967 let result = TemplateEngine::load_data_files(&content);
968 assert!(result.is_empty());
969 }
970
971 #[test]
972 fn render_page_propagates_render_errors() {
973 let dir = tempdir().unwrap();
974 let tera_dir = dir.path().join("tera");
975 fs::create_dir_all(&tera_dir).unwrap();
976 fs::write(
978 tera_dir.join("broken.html"),
979 r"{{ page.title | nonexistent_filter }}",
980 )
981 .unwrap();
982
983 let config = TemplateConfig {
984 template_dir: tera_dir,
985 ..Default::default()
986 };
987 let engine = TemplateEngine::init(config).unwrap().unwrap();
988
989 let mut fm = HashMap::new();
990 let _ = fm.insert(
991 "title".to_string(),
992 serde_json::Value::String("T".to_string()),
993 );
994
995 let result =
996 engine.render_page("broken.html", "", &fm, &HashMap::new());
997 assert!(result.is_err());
998 }
999
1000 #[test]
1011 fn empty_config_values_are_left_out_so_template_defaults_apply() {
1012 let dir = tempdir().unwrap();
1013 let tera_dir = dir.path().join("tera");
1014 fs::create_dir_all(&tera_dir).unwrap();
1015 fs::write(
1016 tera_dir.join("t.html"),
1017 r#"[{{ site.title | default("Home") }}][{{ site.title | upper }}][{% if site.title %}set{% else %}unset{% endif %}][{{ site.name }}]"#,
1018 )
1019 .unwrap();
1020 let engine = TemplateEngine::init(TemplateConfig {
1021 template_dir: tera_dir,
1022 ..Default::default()
1023 })
1024 .unwrap()
1025 .unwrap();
1026
1027 let config = crate::cmd::SsgConfig::builder()
1028 .site_name("Demo".to_string())
1029 .site_title(String::new())
1030 .build()
1031 .unwrap();
1032 let globals = TemplateEngine::site_globals_from_config(&config);
1033 assert!(
1034 !globals.contains_key("title"),
1035 "an empty title is left out: {globals:?}"
1036 );
1037 assert_eq!(
1038 globals.get("name").and_then(serde_json::Value::as_str),
1039 Some("Demo")
1040 );
1041
1042 let out = engine
1043 .render_page("t.html", "", &HashMap::new(), &globals)
1044 .unwrap();
1045 assert_eq!(out, "[Home][][unset][Demo]");
1046 }
1047}