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();
271 let _ = globals.insert(
272 "name".to_string(),
273 serde_json::Value::String(config.site_name.clone()),
274 );
275 let _ = globals.insert(
276 "title".to_string(),
277 serde_json::Value::String(config.site_title.clone()),
278 );
279 let _ = globals.insert(
280 "description".to_string(),
281 serde_json::Value::String(config.site_description.clone()),
282 );
283 let _ = globals.insert(
284 "base_url".to_string(),
285 serde_json::Value::String(config.base_url.clone()),
286 );
287 let _ = globals.insert(
288 "language".to_string(),
289 serde_json::Value::String(config.language.clone()),
290 );
291 globals
292 }
293
294 #[must_use]
313 pub fn load_data_files(
314 content_dir: &std::path::Path,
315 ) -> HashMap<String, serde_json::Value> {
316 let data_dir = content_dir.parent().unwrap_or(content_dir).join("data");
317 let mut data = HashMap::new();
318
319 if !data_dir.exists() {
320 return data;
321 }
322
323 let Ok(entries) = std::fs::read_dir(&data_dir) else {
324 return data;
325 };
326
327 for entry in entries.flatten() {
328 let path = entry.path();
329 if !path.is_file() {
330 continue;
331 }
332
333 let stem = path
334 .file_stem()
335 .unwrap_or_default()
336 .to_string_lossy()
337 .to_string();
338 let ext = path
339 .extension()
340 .unwrap_or_default()
341 .to_string_lossy()
342 .to_lowercase();
343
344 let Ok(content) = std::fs::read_to_string(&path) else {
345 continue;
346 };
347
348 let value: Option<serde_json::Value> = match ext.as_str() {
349 "toml" => match toml::from_str::<serde_json::Value>(&content) {
350 Ok(v) => Some(v),
351 Err(e) => {
352 log::warn!(
353 "Failed to parse data file {}: {e}",
354 path.display()
355 );
356 None
357 }
358 },
359 "json" => match serde_json::from_str(&content) {
360 Ok(v) => Some(v),
361 Err(e) => {
362 log::warn!(
363 "Failed to parse data file {}: {e}",
364 path.display()
365 );
366 None
367 }
368 },
369 "yml" | "yaml" => {
370 match noyalib::from_str::<serde_json::Value>(&content) {
371 Ok(v) => Some(v),
372 Err(e) => {
373 log::warn!(
374 "Failed to parse data file {}: {e}",
375 path.display()
376 );
377 None
378 }
379 }
380 }
381 _ => None,
382 };
383
384 if let Some(val) = value {
385 let _ = data.insert(stem, val);
386 }
387 }
388
389 data
390 }
391}
392
393#[cfg(feature = "templates")]
398fn reading_time_filter(value: String) -> String {
399 let word_count = value.split_whitespace().count();
400 let minutes = (word_count / 200).max(1);
401 format!("{minutes} min read")
402}
403
404#[cfg(feature = "templates")]
408fn slugify_filter(value: String) -> String {
409 value
410 .to_lowercase()
411 .chars()
412 .map(|c| if c.is_alphanumeric() { c } else { '-' })
413 .collect::<String>()
414 .split('-')
415 .filter(|s| !s.is_empty())
416 .collect::<Vec<_>>()
417 .join("-")
418}
419
420#[cfg(all(test, feature = "templates"))]
421mod tests {
422 use super::*;
423 use std::fs;
424 use std::path::Path;
425 use tempfile::tempdir;
426
427 fn setup_templates(dir: &Path) {
428 crate::test_support::init_logger();
429 let tera_dir = dir.join("tera");
430 fs::create_dir_all(&tera_dir).unwrap();
431
432 fs::write(
433 tera_dir.join("base.html"),
434 r#"<!DOCTYPE html>
435<html lang="{{ site.language | default("en") }}">
436<head><title>{% block title %}{{ page.title | default("Untitled") }}{% endblock %}</title>
437{% block head_extra %}{% endblock %}
438</head>
439<body>
440<main>{% block content %}{% endblock %}</main>
441<footer>{% block footer %}<p>© {{ site.name | default("") }}</p>{% endblock %}</footer>
442</body>
443</html>"#,
444 )
445 .unwrap();
446
447 fs::write(
448 tera_dir.join("page.html"),
449 r#"{% extends "base.html" %}
450{% block content %}{{ page.content | safe }}{% endblock %}"#,
451 )
452 .unwrap();
453
454 fs::write(
455 tera_dir.join("post.html"),
456 r#"{% extends "base.html" %}
457{% block content %}
458<article>
459<h1>{{ page.title | default("") }}</h1>
460<time>{{ page.date | default("") }}</time>
461<p>{{ page.content | reading_time }}</p>
462{{ page.content | safe }}
463</article>
464{% endblock %}"#,
465 )
466 .unwrap();
467 }
468
469 #[test]
470 fn test_init_missing_dir() {
471 let config = TemplateConfig {
472 template_dir: PathBuf::from("/nonexistent/path"),
473 ..Default::default()
474 };
475 let result = TemplateEngine::init(config).unwrap();
476 assert!(result.is_none());
477 }
478
479 #[test]
480 fn test_init_and_render_page() {
481 let dir = tempdir().unwrap();
482 setup_templates(dir.path());
483
484 let config = TemplateConfig {
485 template_dir: dir.path().join("tera"),
486 ..Default::default()
487 };
488 let engine = TemplateEngine::init(config).unwrap().unwrap();
489
490 let mut fm = HashMap::new();
491 let _ = fm.insert(
492 "title".to_string(),
493 serde_json::Value::String("Hello".to_string()),
494 );
495
496 let mut site = HashMap::new();
497 let _ = site.insert(
498 "name".to_string(),
499 serde_json::Value::String("My Site".to_string()),
500 );
501 let _ = site.insert(
502 "language".to_string(),
503 serde_json::Value::String("en-GB".to_string()),
504 );
505
506 let result = engine
507 .render_page("page.html", "<p>Body</p>", &fm, &site)
508 .unwrap();
509
510 assert!(result.contains("Hello"));
511 assert!(result.contains("<p>Body</p>"));
512 assert!(result.contains("My Site"));
513 assert!(result.contains("en-GB"));
514 }
515
516 fn engine_with_default_templates(dir: &Path) -> TemplateEngine {
519 setup_templates(dir);
520 let config = TemplateConfig {
521 template_dir: dir.join("tera"),
522 ..Default::default()
523 };
524 TemplateEngine::init(config).unwrap().unwrap()
525 }
526
527 fn site_with_language(lang: &str) -> HashMap<String, serde_json::Value> {
528 let mut site = HashMap::new();
529 let _ = site.insert(
530 "language".to_string(),
531 serde_json::Value::String(lang.to_string()),
532 );
533 site
534 }
535
536 #[test]
537 fn frontmatter_language_wins_over_site_language_in_html_lang() {
538 let dir = tempdir().unwrap();
542 let engine = engine_with_default_templates(dir.path());
543
544 let mut fm = HashMap::new();
545 let _ = fm.insert(
546 "language".to_string(),
547 serde_json::Value::String("hi".to_string()),
548 );
549
550 let result = engine
551 .render_page(
552 "page.html",
553 "<p>B</p>",
554 &fm,
555 &site_with_language("en-GB"),
556 )
557 .unwrap();
558 assert!(
559 result.contains(r#"<html lang="hi">"#),
560 "front-matter language must win: {result}"
561 );
562 assert!(!result.contains(r#"lang="en-GB""#));
563 }
564
565 #[test]
566 fn frontmatter_hreflang_used_when_language_absent() {
567 let dir = tempdir().unwrap();
568 let engine = engine_with_default_templates(dir.path());
569
570 let mut fm = HashMap::new();
571 let _ = fm.insert(
572 "hreflang".to_string(),
573 serde_json::Value::String("fr_fr".to_string()),
574 );
575
576 let result = engine
577 .render_page(
578 "page.html",
579 "<p>B</p>",
580 &fm,
581 &site_with_language("en"),
582 )
583 .unwrap();
584 assert!(
586 result.contains(r#"<html lang="fr-FR">"#),
587 "hreflang should be used and normalised: {result}"
588 );
589 }
590
591 #[test]
592 fn site_language_used_when_page_has_no_lang_signal() {
593 let dir = tempdir().unwrap();
594 let engine = engine_with_default_templates(dir.path());
595
596 let result = engine
597 .render_page(
598 "page.html",
599 "<p>B</p>",
600 &HashMap::new(),
601 &site_with_language("en-GB"),
602 )
603 .unwrap();
604 assert!(result.contains(r#"<html lang="en-GB">"#));
605 }
606
607 #[test]
608 fn en_fallback_when_no_language_anywhere() {
609 let dir = tempdir().unwrap();
610 let engine = engine_with_default_templates(dir.path());
611
612 let result = engine
613 .render_page(
614 "page.html",
615 "<p>B</p>",
616 &HashMap::new(),
617 &HashMap::new(),
618 )
619 .unwrap();
620 assert!(result.contains(r#"<html lang="en">"#));
621 }
622
623 #[test]
624 fn test_render_post_with_reading_time() {
625 let dir = tempdir().unwrap();
626 setup_templates(dir.path());
627
628 let config = TemplateConfig {
629 template_dir: dir.path().join("tera"),
630 ..Default::default()
631 };
632 let engine = TemplateEngine::init(config).unwrap().unwrap();
633
634 let content = "word ".repeat(600); let mut fm = HashMap::new();
636 let _ = fm.insert(
637 "title".to_string(),
638 serde_json::Value::String("Post".to_string()),
639 );
640 let _ = fm.insert(
641 "date".to_string(),
642 serde_json::Value::String("2026-01-01".to_string()),
643 );
644
645 let site = HashMap::new();
646 let result = engine
647 .render_page("post.html", &content, &fm, &site)
648 .unwrap();
649
650 assert!(result.contains("3 min read"));
651 assert!(result.contains("<article>"));
652 }
653
654 #[test]
655 fn test_fallback_to_page_html() {
656 let dir = tempdir().unwrap();
657 setup_templates(dir.path());
658
659 let config = TemplateConfig {
660 template_dir: dir.path().join("tera"),
661 ..Default::default()
662 };
663 let engine = TemplateEngine::init(config).unwrap().unwrap();
664
665 let fm = HashMap::new();
666 let site = HashMap::new();
667 let result = engine
668 .render_page("nonexistent.html", "<p>fallback</p>", &fm, &site)
669 .unwrap();
670
671 assert!(result.contains("<p>fallback</p>"));
672 }
673
674 #[test]
675 fn test_reading_time_filter_direct() {
676 let text = "word ".repeat(400);
677 let result = reading_time_filter(text);
678 assert_eq!(result, "2 min read");
679 }
680
681 #[test]
682 fn test_slugify_filter() {
683 assert_eq!(slugify_filter("Hello World!".to_string()), "hello-world");
684 assert_eq!(slugify_filter("Rust & Web".to_string()), "rust-web");
685 }
686
687 #[test]
692 fn load_data_files_missing_data_dir_returns_empty_map() {
693 let dir = tempdir().unwrap();
694 let content = dir.path().join("content");
695 fs::create_dir_all(&content).unwrap();
696 let result = TemplateEngine::load_data_files(&content);
697 assert!(result.is_empty());
698 }
699
700 #[test]
701 fn load_data_files_parses_toml_and_json_and_yaml() {
702 let dir = tempdir().unwrap();
703 let content = dir.path().join("content");
704 fs::create_dir_all(&content).unwrap();
705 let data = dir.path().join("data");
706 fs::create_dir_all(&data).unwrap();
707
708 fs::write(data.join("site.toml"), r#"key = "toml-value""#).unwrap();
709 fs::write(data.join("nav.json"), r#"{"items": ["home", "about"]}"#)
710 .unwrap();
711 fs::write(data.join("conf.yml"), r#"{"yaml": "value"}"#).unwrap();
712 fs::write(data.join("ignored.txt"), "not parsed").unwrap();
713
714 let sub = data.join("sub");
715 fs::create_dir_all(&sub).unwrap();
716 fs::write(sub.join("inside.json"), "{}").unwrap();
717
718 let result = TemplateEngine::load_data_files(&content);
719 assert!(result.contains_key("site"));
720 assert!(result.contains_key("nav"));
721 assert!(result.contains_key("conf"));
722 assert!(!result.contains_key("ignored"));
723 assert!(!result.contains_key("sub"));
724 }
725
726 #[test]
727 fn load_data_files_skips_files_with_invalid_content() {
728 let dir = tempdir().unwrap();
729 let content = dir.path().join("content");
730 fs::create_dir_all(&content).unwrap();
731 let data = dir.path().join("data");
732 fs::create_dir_all(&data).unwrap();
733
734 fs::write(data.join("broken.toml"), "not valid toml [[[").unwrap();
735 fs::write(data.join("broken.json"), "{not valid").unwrap();
736 fs::write(data.join("good.toml"), r#"x = "y""#).unwrap();
737
738 let result = TemplateEngine::load_data_files(&content);
739 assert!(result.contains_key("good"));
740 assert!(!result.contains_key("broken"));
741 }
742
743 #[test]
744 fn load_data_files_skips_non_utf8_file() {
745 let dir = tempdir().unwrap();
748 let content = dir.path().join("content");
749 fs::create_dir_all(&content).unwrap();
750 let data = dir.path().join("data");
751 fs::create_dir_all(&data).unwrap();
752
753 fs::write(data.join("binary.toml"), [0xFF, 0xFE, 0x00, 0x01]).unwrap();
754 fs::write(data.join("ok.toml"), r#"k = "v""#).unwrap();
755
756 let result = TemplateEngine::load_data_files(&content);
757 assert!(result.contains_key("ok"));
758 assert!(!result.contains_key("binary"));
759 }
760
761 #[test]
762 fn load_data_files_skips_invalid_yaml() {
763 crate::test_support::init_logger();
766 let dir = tempdir().unwrap();
767 let content = dir.path().join("content");
768 fs::create_dir_all(&content).unwrap();
769 let data = dir.path().join("data");
770 fs::create_dir_all(&data).unwrap();
771
772 fs::write(data.join("broken.yml"), "key: [unclosed").unwrap();
773 fs::write(data.join("good.yaml"), "k: v").unwrap();
774
775 let result = TemplateEngine::load_data_files(&content);
776 assert!(result.contains_key("good"));
777 assert!(!result.contains_key("broken"));
778 }
779
780 #[test]
781 fn load_data_files_ignores_unsupported_extensions() {
782 let dir = tempdir().unwrap();
783 let content = dir.path().join("content");
784 fs::create_dir_all(&content).unwrap();
785 let data = dir.path().join("data");
786 fs::create_dir_all(&data).unwrap();
787
788 fs::write(data.join("a.xml"), "<x/>").unwrap();
789 fs::write(data.join("b.csv"), "a,b").unwrap();
790 fs::write(data.join("c"), "no extension").unwrap();
791
792 let result = TemplateEngine::load_data_files(&content);
793 assert!(result.is_empty());
794 }
795
796 #[test]
801 fn render_page_injects_custom_globals_from_config() {
802 let dir = tempdir().unwrap();
803 setup_templates(dir.path());
804
805 fs::write(
807 dir.path().join("tera").join("branded.html"),
808 r"<p>{{ brand }}</p>",
809 )
810 .unwrap();
811
812 let config = TemplateConfig {
813 template_dir: dir.path().join("tera"),
814 globals: {
815 let mut g = HashMap::new();
816 let _ = g.insert(
817 "brand".to_string(),
818 serde_json::Value::String("Acme".to_string()),
819 );
820 g
821 },
822 ..Default::default()
823 };
824 let engine = TemplateEngine::init(config).unwrap().unwrap();
825
826 let result = engine
827 .render_page("branded.html", "", &HashMap::new(), &HashMap::new())
828 .unwrap();
829 assert!(result.contains("Acme"));
830 }
831
832 #[test]
833 fn render_page_no_matching_template_and_no_page_html_returns_content_as_is()
834 {
835 let dir = tempdir().unwrap();
836 let tera_dir = dir.path().join("tera");
837 fs::create_dir_all(&tera_dir).unwrap();
838 fs::write(
840 tera_dir.join("base.html"),
841 r"<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>",
842 )
843 .unwrap();
844
845 let config = TemplateConfig {
846 template_dir: tera_dir,
847 ..Default::default()
848 };
849 let engine = TemplateEngine::init(config).unwrap().unwrap();
850
851 let content = "<p>raw content</p>";
852 let result = engine
853 .render_page(
854 "nonexistent.html",
855 content,
856 &HashMap::new(),
857 &HashMap::new(),
858 )
859 .unwrap();
860 assert_eq!(result, content);
861 }
862
863 #[test]
864 fn init_with_autoescape_false() {
865 let dir = tempdir().unwrap();
866 setup_templates(dir.path());
867
868 let config = TemplateConfig {
869 template_dir: dir.path().join("tera"),
870 autoescape: false,
871 ..Default::default()
872 };
873 let engine = TemplateEngine::init(config).unwrap().unwrap();
874 let result = engine
875 .render_page(
876 "page.html",
877 "<p>x</p>",
878 &HashMap::new(),
879 &HashMap::new(),
880 )
881 .unwrap();
882 assert!(result.contains("<p>x</p>"));
883 }
884
885 #[test]
886 fn init_with_broken_template_errors_on_render() {
887 let dir = tempdir().unwrap();
888 let tera_dir = dir.path().join("tera");
889 fs::create_dir_all(&tera_dir).unwrap();
890 fs::write(tera_dir.join("broken.html"), "{% extends \"nonexistent_parent.html\" %}{% block x %}{% endblock %}").unwrap();
892
893 let config = TemplateConfig {
894 template_dir: tera_dir,
895 ..Default::default()
896 };
897 let engine = TemplateEngine::init(config).unwrap().unwrap();
899 let result = engine.render_page(
901 "broken.html",
902 "",
903 &HashMap::new(),
904 &HashMap::new(),
905 );
906 assert!(result.is_err());
907 }
908
909 #[test]
910 #[cfg(unix)]
911 fn load_data_files_unreadable_file_continues_silently() {
912 let dir = tempdir().unwrap();
913 let content = dir.path().join("content");
914 fs::create_dir_all(&content).unwrap();
915 let data = dir.path().join("data");
916 fs::create_dir_all(&data).unwrap();
917
918 fs::create_dir_all(data.join("not-really.toml")).unwrap();
919 fs::write(data.join("real.toml"), r#"k = "v""#).unwrap();
920
921 let result = TemplateEngine::load_data_files(&content);
922 assert!(result.contains_key("real"));
923 assert!(!result.contains_key("not-really"));
924 }
925
926 #[test]
927 fn load_data_files_data_dir_is_a_file_returns_empty() {
928 let dir = tempdir().unwrap();
929 let content = dir.path().join("content");
930 fs::create_dir_all(&content).unwrap();
931 let data = dir.path().join("data");
932 fs::write(&data, "I am a file, not a directory").unwrap();
933
934 let result = TemplateEngine::load_data_files(&content);
935 assert!(result.is_empty());
936 }
937
938 #[test]
939 fn render_page_propagates_render_errors() {
940 let dir = tempdir().unwrap();
941 let tera_dir = dir.path().join("tera");
942 fs::create_dir_all(&tera_dir).unwrap();
943 fs::write(
945 tera_dir.join("broken.html"),
946 r"{{ page.title | nonexistent_filter }}",
947 )
948 .unwrap();
949
950 let config = TemplateConfig {
951 template_dir: tera_dir,
952 ..Default::default()
953 };
954 let engine = TemplateEngine::init(config).unwrap().unwrap();
955
956 let mut fm = HashMap::new();
957 let _ = fm.insert(
958 "title".to_string(),
959 serde_json::Value::String("T".to_string()),
960 );
961
962 let result =
963 engine.render_page("broken.html", "", &fm, &HashMap::new());
964 assert!(result.is_err());
965 }
966}