1#[cfg(feature = "templates")]
10use crate::{
11 error::{PathErrorExt, SsgError},
12 frontmatter,
13 plugin::{Plugin, PluginContext},
14 template_engine::{TemplateConfig, TemplateEngine},
15 MAX_DIR_DEPTH,
16};
17
18#[cfg(feature = "templates")]
19use std::{
20 collections::HashMap,
21 fs,
22 path::{Path, PathBuf},
23};
24
25#[cfg(feature = "templates")]
41#[derive(Debug)]
42pub struct TemplatePlugin {
43 config: TemplateConfig,
44}
45
46#[cfg(feature = "templates")]
47impl TemplatePlugin {
48 #[must_use]
61 pub const fn new(config: TemplateConfig) -> Self {
62 Self { config }
63 }
64
65 #[must_use]
79 pub fn from_template_dir(template_dir: &Path) -> Self {
80 Self {
81 config: TemplateConfig {
82 template_dir: template_dir.join("tera"),
83 ..Default::default()
84 },
85 }
86 }
87}
88
89#[cfg(feature = "templates")]
90impl Plugin for TemplatePlugin {
91 fn name(&self) -> &'static str {
92 "templates"
93 }
94
95 fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
96 let sidecar_dir = ctx.build_dir.join(".meta");
98 let count = frontmatter::emit_sidecars(&ctx.content_dir, &sidecar_dir)
99 .map_err(|e| SsgError::io(e, &sidecar_dir))?;
100 if count > 0 {
101 log::info!("[templates] Emitted {count} frontmatter sidecar(s)");
102 }
103 Ok(())
104 }
105
106 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
107 let config = TemplateConfig {
115 template_dir: ctx.template_dir.join("tera"),
116 ..self.config.clone()
117 };
118 let template_dir = config.template_dir.clone();
119 let Some(engine) = TemplateEngine::init(config)
120 .map_err(|e| SsgError::io(e, &template_dir))?
121 else {
122 log::info!(
123 "[templates] No templates at {}, skipping",
124 template_dir.display()
125 );
126 return Ok(());
127 };
128
129 let mut site_globals = ctx
131 .config
132 .as_ref()
133 .map(TemplateEngine::site_globals_from_config)
134 .unwrap_or_default();
135
136 let data_files = TemplateEngine::load_data_files(&ctx.content_dir);
138 if !data_files.is_empty() {
139 let _ = site_globals.insert(
140 "data".to_string(),
141 serde_json::Value::Object(data_files.into_iter().collect()),
142 );
143 }
144
145 let sidecar_dir = resolve_sidecar_dir(ctx);
146 let html_files = collect_html_files(&ctx.site_dir)?;
147 let enriched_fm_map =
148 enrich_with_related_posts(&html_files, &ctx.site_dir, &sidecar_dir);
149
150 let mut rendered = 0usize;
151 let mut skipped = 0usize;
152 for html_path in &html_files {
153 let content = fs::read_to_string(html_path).with_path(html_path)?;
154
155 let fm =
157 enriched_fm_map.get(html_path).cloned().unwrap_or_else(|| {
158 read_frontmatter_for_html(
159 html_path,
160 &ctx.site_dir,
161 &sidecar_dir,
162 )
163 });
164
165 let layout =
167 fm.get("layout").and_then(|v| v.as_str()).unwrap_or("page");
168 let template_name = format!("{layout}.html");
169
170 if !engine.has_template(&template_name)
176 && !engine.has_template("page.html")
177 {
178 log::warn!(
179 "[templates] No template '{template_name}' (and no page.html fallback) for {}; leaving compiled output untouched",
180 html_path.display()
181 );
182 skipped += 1;
183 continue;
184 }
185
186 match engine.render_page(
187 &template_name,
188 page_fragment(&content),
189 &fm,
190 &site_globals,
191 ) {
192 Ok(output) => {
193 fs::write(html_path, output).with_path(html_path)?;
194 rendered += 1;
195 }
196 Err(e) => {
197 log::warn!(
198 "[templates] Failed to render {}: {e}",
199 html_path.display()
200 );
201 }
202 }
203 }
204
205 if rendered > 0 {
206 log::info!("[templates] Rendered {rendered} page(s)");
207 }
208 if skipped > 0 {
209 log::warn!(
210 "[templates] Skipped {skipped} page(s) with no matching template"
211 );
212 }
213 Ok(())
214 }
215}
216
217#[cfg(feature = "templates")]
232fn resolve_sidecar_dir(ctx: &PluginContext) -> PathBuf {
233 let build_meta = ctx.build_dir.join(".meta");
234 if build_meta.is_dir() {
235 build_meta
236 } else {
237 ctx.site_dir.join(".meta")
238 }
239}
240
241#[cfg(feature = "templates")]
243fn read_frontmatter_for_html(
244 html_path: &Path,
245 site_dir: &Path,
246 sidecar_dir: &Path,
247) -> HashMap<String, serde_json::Value> {
248 let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
249 let mut candidates =
254 vec![sidecar_dir.join(rel).with_extension("meta.json")];
255 if rel.file_name().is_some_and(|name| name == "index.html") {
256 if let Some(dir) = rel.parent().filter(|p| !p.as_os_str().is_empty()) {
257 candidates.push(sidecar_dir.join(dir).with_extension("meta.json"));
258 }
259 }
260 for sidecar in candidates.iter().filter(|p| p.exists()) {
261 if let Ok(content) = fs::read_to_string(sidecar) {
262 if let Ok(meta) = serde_json::from_str(&content) {
263 return meta;
264 }
265 }
266 }
267 HashMap::new()
268}
269
270#[cfg(feature = "templates")]
281fn page_fragment(html: &str) -> &str {
282 element_inner(html, "main")
283 .or_else(|| element_inner(html, "body"))
284 .unwrap_or(html)
285}
286
287#[cfg(feature = "templates")]
291fn element_inner<'a>(html: &'a str, name: &str) -> Option<&'a str> {
292 let lower = html.to_ascii_lowercase();
295 let open_tag = format!("<{name}");
296 let close_tag = format!("</{name}>");
297 let mut from = 0;
298 let open = loop {
299 let at = lower[from..].find(&open_tag)? + from;
300 let next = lower.as_bytes().get(at + open_tag.len());
301 if matches!(next, Some(b'>' | b' ' | b'\t' | b'\n' | b'\r')) {
302 break at;
303 }
304 from = at + open_tag.len();
305 };
306 let start = lower[open..].find('>')? + open + 1;
307 let end = lower[start..].rfind(&close_tag)? + start;
308 Some(&html[start..end])
309}
310
311#[cfg(feature = "templates")]
313fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
314 crate::walk::walk_files_bounded_depth(dir, "html", MAX_DIR_DEPTH)
315}
316
317#[cfg(feature = "templates")]
319fn enrich_with_related_posts(
320 html_files: &[PathBuf],
321 site_dir: &Path,
322 sidecar_dir: &Path,
323) -> HashMap<PathBuf, HashMap<String, serde_json::Value>> {
324 let mut pages_meta = HashMap::new();
325
326 for html_path in html_files {
327 let fm = read_frontmatter_for_html(html_path, site_dir, sidecar_dir);
328 let mut terms = std::collections::HashSet::new();
329
330 if let Some(tags_val) = fm.get("tags") {
331 if let Some(arr) = tags_val.as_array() {
332 for item in arr {
333 if let Some(s) = item.as_str() {
334 let _ = terms.insert(s.to_string());
335 }
336 }
337 } else if let Some(s) = tags_val.as_str() {
338 for term in ssg_core::split_terms(s) {
339 let _ = terms.insert(term);
340 }
341 }
342 }
343 if let Some(cats_val) = fm.get("categories") {
344 if let Some(arr) = cats_val.as_array() {
345 for item in arr {
346 if let Some(s) = item.as_str() {
347 let _ = terms.insert(s.to_string());
348 }
349 }
350 } else if let Some(s) = cats_val.as_str() {
351 for term in ssg_core::split_terms(s) {
352 let _ = terms.insert(term);
353 }
354 }
355 }
356
357 let title = fm
358 .get("title")
359 .and_then(|v| v.as_str())
360 .unwrap_or("Untitled")
361 .to_string();
362 let date = fm
363 .get("date")
364 .and_then(|v| v.as_str())
365 .unwrap_or("")
366 .to_string();
367 let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
368 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
369
370 let _ =
371 pages_meta.insert(html_path.clone(), (fm, title, terms, date, url));
372 }
373
374 let mut enriched = HashMap::new();
375 for (html_path, (fm, _title, terms, _date, _url)) in &pages_meta {
376 let mut candidates = Vec::new();
377
378 for (
379 other_path,
380 (_other_fm, other_title, other_terms, other_date, other_url),
381 ) in &pages_meta
382 {
383 if other_path == html_path {
384 continue;
385 }
386
387 let overlap = terms.intersection(other_terms).count();
388 if overlap > 0 {
389 candidates.push((
390 overlap,
391 other_date.clone(),
392 other_title.clone(),
393 other_url.clone(),
394 ));
395 }
396 }
397
398 candidates.sort_by(|a, b| {
411 b.0.cmp(&a.0)
412 .then_with(|| b.1.cmp(&a.1))
413 .then_with(|| a.3.cmp(&b.3))
414 });
415
416 let top_3: Vec<serde_json::Value> = candidates
417 .into_iter()
418 .take(3)
419 .map(|(_overlap, other_date, other_title, other_url)| {
420 let mut obj = serde_json::Map::new();
421 let _ = obj.insert(
422 "title".to_string(),
423 serde_json::Value::String(other_title),
424 );
425 let _ = obj.insert(
426 "url".to_string(),
427 serde_json::Value::String(other_url),
428 );
429 let _ = obj.insert(
430 "date".to_string(),
431 serde_json::Value::String(other_date),
432 );
433 serde_json::Value::Object(obj)
434 })
435 .collect();
436
437 let mut new_fm = fm.clone();
438 let _ = new_fm.insert(
439 "related_posts".to_string(),
440 serde_json::Value::Array(top_3),
441 );
442 let _ = enriched.insert(html_path.clone(), new_fm);
443 }
444
445 enriched
446}
447
448#[cfg(all(test, feature = "templates"))]
449mod tests {
450 use super::*;
451 use crate::cmd::SsgConfig;
452 use crate::test_support::init_logger;
453 use std::fs;
454 use tempfile::{tempdir, TempDir};
455
456 fn layout() -> (TempDir, PathBuf, PathBuf, PathBuf, PathBuf) {
461 init_logger();
462 let dir = tempdir().expect("tempdir");
463 let content = dir.path().join("content");
464 let build = dir.path().join("build");
465 let site = dir.path().join("site");
466 let templates = dir.path().join("templates/tera");
467 for d in [&content, &build, &site, &templates] {
468 fs::create_dir_all(d).expect("mkdir");
469 }
470 (dir, content, build, site, templates)
471 }
472
473 fn make_config(root: &Path) -> SsgConfig {
474 SsgConfig {
475 listings: Vec::new(),
476 site_name: "Test".to_string(),
477 site_title: "Test Site".to_string(),
478 site_description: "Desc".to_string(),
479 base_url: "http://localhost".to_string(),
480 language: "en-GB".to_string(),
481 content_dir: root.join("content"),
482 output_dir: root.join("build"),
483 template_dir: root.join("templates"),
484 theme: None,
485 serve_dir: None,
486 #[cfg(feature = "i18n")]
487 i18n: None,
488 cdn_prefix: None,
489 og_image: None,
490 image: crate::cmd::ImageConfig::default(),
491 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
492 agents: None,
493 transitions: false,
494 security: crate::cmd::SecurityConfig::default(),
495 no_taxonomy_pages: false,
496 }
497 }
498
499 fn setup_project(dir: &Path) {
500 let content = dir.join("content");
501 let build = dir.join("build");
502 let site = dir.join("site");
503 let templates = dir.join("templates/tera");
504 fs::create_dir_all(&content).unwrap();
505 fs::create_dir_all(&build).unwrap();
506 fs::create_dir_all(&site).unwrap();
507 fs::create_dir_all(&templates).unwrap();
508
509 fs::write(
510 templates.join("base.html"),
511 r#"<!DOCTYPE html>
512<html><head><title>{{ page.title | default("") }}</title></head>
513<body>{% block content %}{% endblock %}</body></html>"#,
514 )
515 .unwrap();
516
517 fs::write(
518 templates.join("page.html"),
519 r#"{% extends "base.html" %}
520{% block content %}{{ page.content | safe }}{% endblock %}"#,
521 )
522 .unwrap();
523
524 fs::write(
525 content.join("index.md"),
526 "---\ntitle: Home\nlayout: page\n---\n# Welcome\n",
527 )
528 .unwrap();
529
530 fs::write(site.join("index.html"), "<h1>Welcome</h1>").unwrap();
531
532 let meta_dir = build.join(".meta");
533 fs::create_dir_all(&meta_dir).unwrap();
534 fs::write(
535 meta_dir.join("index.meta.json"),
536 r#"{"title": "Home", "layout": "page"}"#,
537 )
538 .unwrap();
539 }
540
541 #[test]
552 fn layout_resolves_when_build_dir_was_promoted_to_site_dir() {
553 init_logger();
554 let dir = tempdir().unwrap();
555 let content = dir.path().join("content");
556 let build = dir.path().join("build");
557 let site = dir.path().join("site");
558 let templates = dir.path().join("templates/tera");
559 for d in [&content, &build, &site, &templates] {
560 fs::create_dir_all(d).unwrap();
561 }
562
563 fs::write(templates.join("page.html"), "FALLBACK-PAGE").unwrap();
565 fs::write(templates.join("custom.html"), "CUSTOM-LAYOUT").unwrap();
566 fs::write(site.join("index.html"), "<h1>compiled</h1>").unwrap();
567
568 let meta_dir = site.join(".meta");
571 fs::create_dir_all(&meta_dir).unwrap();
572 fs::write(
573 meta_dir.join("index.meta.json"),
574 r#"{"title": "Home", "layout": "custom"}"#,
575 )
576 .unwrap();
577 assert!(
578 !build.join(".meta").exists(),
579 "fixture must model the promoted layout"
580 );
581
582 let plugin = TemplatePlugin::new(TemplateConfig {
583 template_dir: templates,
584 ..Default::default()
585 });
586 let config = make_config(dir.path());
587 let ctx = PluginContext::with_config(
588 &content,
589 &build,
590 &site,
591 &dir.path().join("templates"),
592 config,
593 );
594
595 plugin.after_compile(&ctx).unwrap();
596
597 let out = fs::read_to_string(site.join("index.html")).unwrap();
598 assert!(
599 out.contains("CUSTOM-LAYOUT"),
600 "expected the `custom` layout from the sidecar, got: {out}"
601 );
602 assert!(
603 !out.contains("FALLBACK-PAGE"),
604 "must not silently fall back to page.html: {out}"
605 );
606 }
607
608 #[test]
612 fn missing_template_leaves_output_untouched_and_is_not_counted() {
613 init_logger();
614 let dir = tempdir().unwrap();
615 let content = dir.path().join("content");
616 let build = dir.path().join("build");
617 let site = dir.path().join("site");
618 let templates = dir.path().join("templates/tera");
619 for d in [&content, &build, &site, &templates] {
620 fs::create_dir_all(d).unwrap();
621 }
622
623 fs::write(templates.join("other.html"), "OTHER").unwrap();
626 fs::write(site.join("index.html"), "<h1>compiled</h1>").unwrap();
627 let meta_dir = site.join(".meta");
628 fs::create_dir_all(&meta_dir).unwrap();
629 fs::write(meta_dir.join("index.meta.json"), r#"{"layout": "nope"}"#)
630 .unwrap();
631
632 let plugin = TemplatePlugin::new(TemplateConfig {
633 template_dir: templates,
634 ..Default::default()
635 });
636 let config = make_config(dir.path());
637 let ctx = PluginContext::with_config(
638 &content,
639 &build,
640 &site,
641 &dir.path().join("templates"),
642 config,
643 );
644
645 plugin.after_compile(&ctx).unwrap();
646
647 let out = fs::read_to_string(site.join("index.html")).unwrap();
648 assert_eq!(
649 out, "<h1>compiled</h1>",
650 "compiled output must survive untouched"
651 );
652 }
653
654 #[test]
655 fn test_template_plugin_renders() {
656 init_logger();
657 let dir = tempdir().unwrap();
658 setup_project(dir.path());
659
660 let plugin = TemplatePlugin::new(TemplateConfig {
661 template_dir: dir.path().join("templates/tera"),
662 ..Default::default()
663 });
664
665 let config = SsgConfig {
666 listings: Vec::new(),
667 site_name: "Test".to_string(),
668 site_title: "Test Site".to_string(),
669 site_description: "Desc".to_string(),
670 base_url: "http://localhost".to_string(),
671 language: "en-GB".to_string(),
672 content_dir: dir.path().join("content"),
673 output_dir: dir.path().join("build"),
674 template_dir: dir.path().join("templates"),
675 theme: None,
676 serve_dir: None,
677 #[cfg(feature = "i18n")]
678 i18n: None,
679 cdn_prefix: None,
680 og_image: None,
681 image: crate::cmd::ImageConfig::default(),
682 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
683 agents: None,
684 transitions: false,
685 security: crate::cmd::SecurityConfig::default(),
686 no_taxonomy_pages: false,
687 };
688
689 let content_dir = config.content_dir.clone();
690 let output_dir = config.output_dir.clone();
691 let template_dir = config.template_dir.clone();
692 let site = dir.path().join("site");
693 let ctx = PluginContext::with_config(
694 &content_dir,
695 &output_dir,
696 &site,
697 &template_dir,
698 config,
699 );
700
701 plugin.after_compile(&ctx).unwrap();
702
703 let output =
704 fs::read_to_string(dir.path().join("site/index.html")).unwrap();
705 assert!(output.contains("<!DOCTYPE html>"));
706 assert!(output.contains("Home"));
707 assert!(output.contains("<h1>Welcome</h1>"));
708 }
709
710 #[test]
711 fn test_template_plugin_skips_missing_templates() {
712 let dir = tempdir().unwrap();
713 let site = dir.path().join("site");
714 fs::create_dir_all(&site).unwrap();
715 fs::write(site.join("index.html"), "<p>hello</p>").unwrap();
716
717 let plugin = TemplatePlugin::new(TemplateConfig {
718 template_dir: dir.path().join("nonexistent"),
719 ..Default::default()
720 });
721
722 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
723
724 plugin.after_compile(&ctx).unwrap();
725
726 let output = fs::read_to_string(site.join("index.html")).unwrap();
727 assert_eq!(output, "<p>hello</p>");
728 }
729
730 #[test]
731 fn name_returns_templates_identifier() {
732 let plugin = TemplatePlugin::new(TemplateConfig::default());
733 assert_eq!(plugin.name(), "templates");
734 }
735
736 #[test]
737 fn template_plugin_debug_output_mentions_config() {
738 let plugin = TemplatePlugin::new(TemplateConfig::default());
741 let s = format!("{plugin:?}");
742 assert!(s.contains("TemplatePlugin"), "{s}");
743 }
744
745 #[test]
746 fn new_stores_supplied_config() {
747 let cfg = TemplateConfig {
748 template_dir: std::env::temp_dir().join("ssg_template_fake"),
749 ..Default::default()
750 };
751 let plugin = TemplatePlugin::new(cfg.clone());
752 assert_eq!(plugin.config.template_dir, cfg.template_dir);
753 }
754
755 #[test]
756 fn from_template_dir_nests_under_tera_subdirectory() {
757 let plugin =
758 TemplatePlugin::from_template_dir(Path::new("/my/templates"));
759 assert!(plugin.config.template_dir.ends_with("templates/tera"));
760 }
761
762 #[test]
763 fn before_compile_emits_sidecars_from_content_markdown() {
764 let (_tmp, content, build, _site, templates) = layout();
765 fs::write(content.join("index.md"), "---\ntitle: Test\n---\nbody")
766 .unwrap();
767
768 let plugin = TemplatePlugin::new(TemplateConfig {
769 template_dir: templates,
770 ..Default::default()
771 });
772 let ctx = PluginContext::new(&content, &build, &content, &content);
773
774 plugin.before_compile(&ctx).unwrap();
775 assert!(build.join(".meta").join("index.meta.json").exists());
776 }
777
778 #[test]
779 fn before_compile_no_markdown_files_still_returns_ok() {
780 let (_tmp, content, build, _site, templates) = layout();
781 let plugin = TemplatePlugin::new(TemplateConfig {
782 template_dir: templates,
783 ..Default::default()
784 });
785 let ctx = PluginContext::new(&content, &build, &content, &content);
786 plugin.before_compile(&ctx).unwrap();
787 }
788
789 #[test]
790 fn after_compile_without_config_uses_empty_site_globals() {
791 let dir = tempdir().unwrap();
792 setup_project(dir.path());
793
794 let plugin = TemplatePlugin::new(TemplateConfig {
795 template_dir: dir.path().join("templates/tera"),
796 ..Default::default()
797 });
798 let ctx = PluginContext::new(
799 &dir.path().join("content"),
800 &dir.path().join("build"),
801 &dir.path().join("site"),
802 &dir.path().join("templates"),
803 );
804
805 plugin.after_compile(&ctx).unwrap();
806 let output =
807 fs::read_to_string(dir.path().join("site").join("index.html"))
808 .unwrap();
809 assert!(output.contains("<!DOCTYPE html>"));
810 }
811
812 #[test]
813 fn after_compile_loads_data_files_into_context() {
814 let dir = tempdir().unwrap();
815 setup_project(dir.path());
816
817 let data = dir.path().join("data");
818 fs::create_dir_all(&data).unwrap();
819 fs::write(data.join("nav.toml"), r#"site = "demo""#).unwrap();
820
821 let plugin = TemplatePlugin::new(TemplateConfig {
822 template_dir: dir.path().join("templates/tera"),
823 ..Default::default()
824 });
825 let config = make_config(dir.path());
826 let ctx = PluginContext::with_config(
827 &config.content_dir.clone(),
828 &config.output_dir.clone(),
829 &dir.path().join("site"),
830 &config.template_dir.clone(),
831 config,
832 );
833
834 plugin.after_compile(&ctx).unwrap();
835 let output =
836 fs::read_to_string(dir.path().join("site").join("index.html"))
837 .unwrap();
838 assert!(output.contains("<!DOCTYPE html>"));
839 }
840
841 #[test]
842 fn after_compile_unknown_layout_does_not_propagate_error() {
843 let dir = tempdir().unwrap();
844 setup_project(dir.path());
845
846 let meta_dir = dir.path().join("build").join(".meta");
847 fs::write(
848 meta_dir.join("index.meta.json"),
849 r#"{"title": "Home", "layout": "unknown_layout_999"}"#,
850 )
851 .unwrap();
852
853 let plugin = TemplatePlugin::new(TemplateConfig {
854 template_dir: dir.path().join("templates/tera"),
855 ..Default::default()
856 });
857 let ctx = PluginContext::new(
858 &dir.path().join("content"),
859 &dir.path().join("build"),
860 &dir.path().join("site"),
861 &dir.path().join("templates"),
862 );
863
864 plugin
865 .after_compile(&ctx)
866 .expect("render failure must not propagate");
867 }
868
869 #[test]
870 fn after_compile_default_layout_is_page_when_missing_field() {
871 let dir = tempdir().unwrap();
872 setup_project(dir.path());
873
874 let meta_dir = dir.path().join("build").join(".meta");
875 fs::write(meta_dir.join("index.meta.json"), r#"{"title": "Home"}"#)
876 .unwrap();
877
878 let plugin = TemplatePlugin::new(TemplateConfig {
879 template_dir: dir.path().join("templates/tera"),
880 ..Default::default()
881 });
882 let ctx = PluginContext::new(
883 &dir.path().join("content"),
884 &dir.path().join("build"),
885 &dir.path().join("site"),
886 &dir.path().join("templates"),
887 );
888
889 plugin.after_compile(&ctx).unwrap();
890 let out =
891 fs::read_to_string(dir.path().join("site").join("index.html"))
892 .unwrap();
893 assert!(out.contains("<!DOCTYPE html>"));
894 }
895
896 #[test]
904 fn after_compile_reads_tera_under_the_context_template_dir() {
905 let dir = tempdir().unwrap();
906 setup_project(dir.path());
907 let elsewhere = dir.path().join("elsewhere/tera");
909 fs::create_dir_all(&elsewhere).unwrap();
910 fs::write(
911 elsewhere.join("page.html"),
912 "ELSEWHERE {{ page.content | safe }}",
913 )
914 .unwrap();
915 let plugin = TemplatePlugin::new(TemplateConfig {
916 template_dir: elsewhere,
917 ..Default::default()
918 });
919 let ctx = PluginContext::new(
920 &dir.path().join("content"),
921 &dir.path().join("build"),
922 &dir.path().join("site"),
923 &dir.path().join("templates"),
924 );
925 plugin.after_compile(&ctx).unwrap();
926 let out =
927 fs::read_to_string(dir.path().join("site").join("index.html"))
928 .unwrap();
929 assert!(
930 !out.contains("ELSEWHERE"),
931 "the configured directory must not win over the context: {out}"
932 );
933 assert!(
934 out.contains("<!DOCTYPE html>"),
935 "the context's templates/tera rendered the page: {out}"
936 );
937 }
938
939 const COMPILED_PAGE: &str = r##"<!DOCTYPE html>
946<html lang="en-GB">
947<head>
948 <meta charset="utf-8" />
949 <title>Stage one</title>
950 <link rel="canonical" href="https://example.com/" />
951</head>
952<body>
953 <a href="#main-content" class="sr-only">Skip to main content</a>
954 <header role="banner"><nav aria-label="Main navigation"><a href="/">Home</a></nav></header>
955 <main id="main-content" role="main">
956 <div lang="en-GB"><h1>Welcome</h1><p>Body.</p></div>
957 </main>
958 <footer role="contentinfo"><p>Built with SSG.</p></footer>
959</body>
960</html>"##;
961
962 #[test]
963 fn page_fragment_takes_the_inside_of_main() {
964 let fragment = page_fragment(COMPILED_PAGE);
965 assert_eq!(
966 fragment.trim(),
967 "<div lang=\"en-GB\"><h1>Welcome</h1><p>Body.</p></div>"
968 );
969 }
970
971 #[test]
972 fn page_fragment_falls_back_to_body_without_main() {
973 let html = "<!DOCTYPE html><html><head><title>T</title></head>\
974 <BODY class=\"x\"><p>Only body.</p></BODY></html>";
975 assert_eq!(page_fragment(html).trim(), "<p>Only body.</p>");
976 }
977
978 #[test]
979 fn page_fragment_leaves_a_fragment_alone() {
980 for fragment in [
981 "<h1>Welcome</h1>",
982 "<p>Text with a <mainframe> word and </main> stray.</p>",
983 "",
984 ] {
985 assert_eq!(page_fragment(fragment), fragment);
986 }
987 }
988
989 #[test]
990 fn page_fragment_is_case_insensitive_and_keeps_nested_markup() {
991 let html = "<html><body><MAIN role=\"main\"><section>\
992 <p>a</p></section></MAIN></body></html>";
993 assert_eq!(page_fragment(html), "<section><p>a</p></section>");
994 }
995
996 #[test]
1004 fn after_compile_unwraps_a_compiled_document_before_rendering() {
1005 let dir = tempdir().unwrap();
1006 setup_project(dir.path());
1007 fs::write(
1008 dir.path().join("templates/tera/base.html"),
1009 "<!DOCTYPE html>\n<html><head><title>{{ page.title | default(\"\") }}</title></head>\n\
1010 <body><main id=\"main-content\">{% block content %}{% endblock %}</main></body></html>",
1011 )
1012 .unwrap();
1013 fs::write(dir.path().join("site").join("index.html"), COMPILED_PAGE)
1014 .unwrap();
1015 let plugin = TemplatePlugin::new(TemplateConfig {
1016 template_dir: dir.path().join("templates/tera"),
1017 ..Default::default()
1018 });
1019 let ctx = PluginContext::new(
1020 &dir.path().join("content"),
1021 &dir.path().join("build"),
1022 &dir.path().join("site"),
1023 &dir.path().join("templates"),
1024 );
1025 plugin.after_compile(&ctx).unwrap();
1026 let out =
1027 fs::read_to_string(dir.path().join("site").join("index.html"))
1028 .unwrap();
1029
1030 assert_eq!(out.matches("<!DOCTYPE").count(), 1, "one document: {out}");
1031 assert_eq!(out.matches("<html").count(), 1, "one <html>: {out}");
1032 assert_eq!(out.matches("<main").count(), 1, "one <main>: {out}");
1033 assert!(out.contains("<title>Home</title>"), "layout title: {out}");
1034 assert!(!out.contains("Stage one"), "stage-one head is gone: {out}");
1035 assert!(
1036 out.contains("<h1>Welcome</h1><p>Body.</p>"),
1037 "the rendered content is kept: {out}"
1038 );
1039 assert!(
1040 !out.contains("Skip to main content"),
1041 "stage-one chrome is not duplicated: {out}"
1042 );
1043 }
1044
1045 #[test]
1050 fn read_frontmatter_for_html_direct_sidecar_match() {
1051 let dir = tempdir().unwrap();
1052 let site = dir.path().join("site");
1053 let sidecars = dir.path().join(".meta");
1054 fs::create_dir_all(&site).unwrap();
1055 fs::create_dir_all(&sidecars).unwrap();
1056
1057 let html = site.join("post.html");
1058 fs::write(&html, "").unwrap();
1059 fs::write(sidecars.join("post.meta.json"), r#"{"title": "Direct"}"#)
1060 .unwrap();
1061
1062 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
1063 assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("Direct"));
1064 }
1065
1066 #[test]
1072 fn read_frontmatter_for_html_finds_the_sidecar_of_a_nested_index_page() {
1073 let dir = tempdir().unwrap();
1074 let site = dir.path().join("site");
1075 let sidecars = dir.path().join(".meta");
1076 fs::create_dir_all(site.join("about")).unwrap();
1077 fs::create_dir_all(sidecars.join("blog")).unwrap();
1078
1079 fs::write(site.join("about/index.html"), "").unwrap();
1080 fs::write(sidecars.join("about.meta.json"), r#"{"title": "About"}"#)
1081 .unwrap();
1082 let meta = read_frontmatter_for_html(
1083 &site.join("about/index.html"),
1084 &site,
1085 &sidecars,
1086 );
1087 assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("About"));
1088
1089 fs::create_dir_all(site.join("blog/first-post")).unwrap();
1091 fs::write(site.join("blog/first-post/index.html"), "").unwrap();
1092 fs::write(
1093 sidecars.join("blog/first-post.meta.json"),
1094 r#"{"title": "First", "layout": "post"}"#,
1095 )
1096 .unwrap();
1097 let meta = read_frontmatter_for_html(
1098 &site.join("blog/first-post/index.html"),
1099 &site,
1100 &sidecars,
1101 );
1102 assert_eq!(meta.get("layout").and_then(|v| v.as_str()), Some("post"));
1103
1104 fs::write(site.join("index.html"), "").unwrap();
1106 fs::write(sidecars.join("index.meta.json"), r#"{"title": "Home"}"#)
1107 .unwrap();
1108 let meta = read_frontmatter_for_html(
1109 &site.join("index.html"),
1110 &site,
1111 &sidecars,
1112 );
1113 assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("Home"));
1114 }
1115
1116 #[test]
1117 fn read_frontmatter_for_html_invalid_sidecar_returns_empty() {
1118 let dir = tempdir().unwrap();
1119 let site = dir.path().join("site");
1120 let sidecars = dir.path().join(".meta");
1121 fs::create_dir_all(&site).unwrap();
1122 fs::create_dir_all(&sidecars).unwrap();
1123
1124 let html = site.join("post.html");
1125 fs::write(&html, "").unwrap();
1126 fs::write(sidecars.join("post.meta.json"), "{not valid").unwrap();
1127
1128 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
1129 assert!(meta.is_empty());
1130 }
1131
1132 #[test]
1133 fn read_frontmatter_for_html_no_match_returns_empty_map() {
1134 let dir = tempdir().unwrap();
1135 let site = dir.path().join("site");
1136 let sidecars = dir.path().join(".meta");
1137 fs::create_dir_all(&site).unwrap();
1138 fs::create_dir_all(&sidecars).unwrap();
1139
1140 let html = site.join("ghost.html");
1141 fs::write(&html, "").unwrap();
1142
1143 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
1144 assert!(meta.is_empty());
1145 }
1146
1147 #[test]
1148 fn read_frontmatter_for_html_path_outside_site_dir_uses_full_path_as_rel() {
1149 let dir = tempdir().unwrap();
1156 let site = dir.path().join("site");
1157 let elsewhere = dir.path().join("elsewhere");
1158 let sidecars = dir.path().join(".meta");
1159 fs::create_dir_all(&site).unwrap();
1160 fs::create_dir_all(&elsewhere).unwrap();
1161 fs::create_dir_all(&sidecars).unwrap();
1162
1163 let html = elsewhere.join("post.html");
1164 fs::write(&html, "").unwrap();
1165
1166 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
1167 assert!(
1168 meta.is_empty(),
1169 "no sidecar exists for the full out-of-tree path"
1170 );
1171 }
1172
1173 #[test]
1178 fn collect_html_files_filters_non_html_extensions() {
1179 let dir = tempdir().unwrap();
1180 fs::write(dir.path().join("a.html"), "").unwrap();
1181 fs::write(dir.path().join("b.css"), "").unwrap();
1182 fs::write(dir.path().join("c.js"), "").unwrap();
1183
1184 let files = collect_html_files(dir.path()).unwrap();
1185 assert_eq!(files.len(), 1);
1186 }
1187
1188 #[test]
1189 fn collect_html_files_recurses_into_subdirectories() {
1190 let dir = tempdir().unwrap();
1191 let nested = dir.path().join("blog").join("2026");
1192 fs::create_dir_all(&nested).unwrap();
1193 fs::write(dir.path().join("index.html"), "").unwrap();
1194 fs::write(nested.join("post.html"), "").unwrap();
1195
1196 let files = collect_html_files(dir.path()).unwrap();
1197 assert_eq!(files.len(), 2);
1198 }
1199
1200 #[test]
1201 fn collect_html_files_returns_empty_for_missing_directory() {
1202 let dir = tempdir().unwrap();
1203 let result = collect_html_files(&dir.path().join("missing")).unwrap();
1204 assert!(result.is_empty(), "{:?}", result);
1205 }
1206
1207 #[test]
1208 fn test_template_plugin_from_template_dir() {
1209 let p = TemplatePlugin::from_template_dir(Path::new("t"));
1210 assert_eq!(p.name(), "templates");
1211 }
1212
1213 #[test]
1214 fn test_template_plugin_before_compile_error() {
1215 let dir = tempdir().unwrap();
1216 let content_dir = dir.path().join("content");
1217 fs::create_dir_all(&content_dir).unwrap();
1218 fs::write(content_dir.join("index.md"), "---\ntitle: test\n---\n")
1219 .unwrap();
1220
1221 let build_file = dir.path().join("build_file");
1222 fs::write(&build_file, "").unwrap();
1223
1224 let ctx = PluginContext::new(
1225 &content_dir,
1226 &build_file,
1227 dir.path(),
1228 dir.path(),
1229 );
1230 let res =
1231 TemplatePlugin::new(TemplateConfig::default()).before_compile(&ctx);
1232 assert!(res.is_err());
1233 }
1234
1235 #[test]
1236 #[cfg(unix)]
1237 fn test_template_plugin_after_compile_read_html_error() {
1238 let dir = tempdir().unwrap();
1239 let site_dir = dir.path().join("site");
1240 fs::create_dir_all(&site_dir).unwrap();
1241 let html_path = site_dir.join("test.html");
1242 fs::write(&html_path, "test").unwrap();
1243
1244 #[cfg(unix)]
1245 {
1246 use std::os::unix::fs::PermissionsExt;
1247 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o000))
1248 .unwrap();
1249 }
1250
1251 let templates = dir.path().join("tera");
1252 fs::create_dir_all(&templates).unwrap();
1253 fs::write(templates.join("base.html"), "").unwrap();
1254
1255 let ctx =
1258 PluginContext::new(dir.path(), dir.path(), &site_dir, dir.path());
1259 let plugin = TemplatePlugin::new(TemplateConfig {
1260 template_dir: templates,
1261 ..Default::default()
1262 });
1263 let res = plugin.after_compile(&ctx);
1264 #[cfg(unix)]
1265 {
1266 use std::os::unix::fs::PermissionsExt;
1267 let _ = fs::set_permissions(
1268 &html_path,
1269 fs::Permissions::from_mode(0o644),
1270 );
1271 }
1272 #[cfg(unix)]
1273 assert!(res.is_err());
1274 }
1275
1276 #[test]
1277 fn test_enrich_with_related_posts_direct() {
1278 let dir = tempdir().unwrap();
1279 let site = dir.path().join("site");
1280 let sidecars = dir.path().join(".meta");
1281 fs::create_dir_all(&site).unwrap();
1282 fs::create_dir_all(&sidecars).unwrap();
1283
1284 let html1 = site.join("p1.html");
1285 let html2 = site.join("p2.html");
1286 let html3 = site.join("p3.html");
1287 fs::write(&html1, "").unwrap();
1288 fs::write(&html2, "").unwrap();
1289 fs::write(&html3, "").unwrap();
1290
1291 fs::write(sidecars.join("p1.meta.json"), r#"{"title": "P1", "date": "2026-06-01", "tags": ["rust", "web"], "categories": "coding,tech"}"#).unwrap();
1293 fs::write(sidecars.join("p2.meta.json"), r#"{"title": "P2", "date": "2026-06-02", "tags": "rust,systems", "categories": ["coding"]}"#).unwrap();
1295 fs::write(
1297 sidecars.join("p3.meta.json"),
1298 r#"{"title": "P3", "date": "2026-06-03", "tags": ["other"]}"#,
1299 )
1300 .unwrap();
1301
1302 let files = vec![html1.clone(), html2.clone(), html3.clone()];
1303 let enriched = enrich_with_related_posts(&files, &site, &sidecars);
1304
1305 let p1_fm = enriched.get(&html1).unwrap();
1306 let related_p1 =
1307 p1_fm.get("related_posts").unwrap().as_array().unwrap();
1308 assert_eq!(related_p1.len(), 1);
1309 assert_eq!(related_p1[0]["title"], "P2");
1310 assert_eq!(related_p1[0]["url"], "/p2.html");
1311 assert_eq!(related_p1[0]["date"], "2026-06-02");
1312
1313 let p3_fm = enriched.get(&html3).unwrap();
1314 let related_p3 =
1315 p3_fm.get("related_posts").unwrap().as_array().unwrap();
1316 assert!(related_p3.is_empty(), "{:?}", related_p3);
1317 }
1318
1319 #[test]
1320 fn enrich_with_related_posts_path_outside_site_dir_uses_full_path_as_rel() {
1321 let dir = tempdir().unwrap();
1327 let site = dir.path().join("site");
1328 let elsewhere = dir.path().join("elsewhere");
1329 let sidecars = dir.path().join(".meta");
1330 fs::create_dir_all(&site).unwrap();
1331 fs::create_dir_all(&elsewhere).unwrap();
1332 fs::create_dir_all(&sidecars).unwrap();
1333
1334 let html = elsewhere.join("post.html");
1335 fs::write(&html, "").unwrap();
1336
1337 let files = vec![html.clone()];
1338 let enriched = enrich_with_related_posts(&files, &site, &sidecars);
1339 let fm = enriched.get(&html).unwrap();
1340 assert!(
1343 fm.get("related_posts")
1344 .unwrap()
1345 .as_array()
1346 .unwrap()
1347 .is_empty(),
1348 "{:?}",
1349 fm.get("related_posts").unwrap().as_array().unwrap()
1350 );
1351 }
1352
1353 #[test]
1354 fn after_compile_render_failure_logs_and_leaves_file_untouched() {
1355 init_logger();
1358 let dir = tempdir().unwrap();
1359 setup_project(dir.path());
1360 fs::write(
1361 dir.path().join("templates/tera/page.html"),
1362 r#"{% include "missing-partial.html" %}"#,
1363 )
1364 .unwrap();
1365
1366 let plugin = TemplatePlugin::new(TemplateConfig {
1367 template_dir: dir.path().join("templates/tera"),
1368 ..Default::default()
1369 });
1370 let ctx = PluginContext::new(
1371 &dir.path().join("content"),
1372 &dir.path().join("build"),
1373 &dir.path().join("site"),
1374 &dir.path().join("templates"),
1375 );
1376
1377 plugin
1378 .after_compile(&ctx)
1379 .expect("render failure must not propagate");
1380 let out =
1381 fs::read_to_string(dir.path().join("site").join("index.html"))
1382 .unwrap();
1383 assert_eq!(out, "<h1>Welcome</h1>", "failed render must not rewrite");
1384 }
1385
1386 #[test]
1387 #[cfg(unix)]
1388 fn after_compile_write_failure_propagates() {
1389 use std::os::unix::fs::PermissionsExt;
1390
1391 let dir = tempdir().unwrap();
1394 setup_project(dir.path());
1395 let html = dir.path().join("site").join("index.html");
1396 fs::set_permissions(&html, fs::Permissions::from_mode(0o444)).unwrap();
1397
1398 let plugin = TemplatePlugin::new(TemplateConfig {
1399 template_dir: dir.path().join("templates/tera"),
1400 ..Default::default()
1401 });
1402 let ctx = PluginContext::new(
1403 &dir.path().join("content"),
1404 &dir.path().join("build"),
1405 &dir.path().join("site"),
1406 &dir.path().join("templates"),
1407 );
1408
1409 let result = plugin.after_compile(&ctx);
1410 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1411 assert!(result.is_err(), "read-only output must surface as Err");
1412 }
1413
1414 #[test]
1415 #[cfg(unix)]
1416 fn after_compile_propagates_walk_error_from_unreadable_site_subdir() {
1417 use std::os::unix::fs::PermissionsExt;
1418
1419 let dir = tempdir().unwrap();
1420 setup_project(dir.path());
1421 let locked = dir.path().join("site").join("locked");
1422 fs::create_dir_all(&locked).unwrap();
1423 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1424 .unwrap();
1425
1426 let plugin = TemplatePlugin::new(TemplateConfig {
1427 template_dir: dir.path().join("templates/tera"),
1428 ..Default::default()
1429 });
1430 let ctx = PluginContext::new(
1431 &dir.path().join("content"),
1432 &dir.path().join("build"),
1433 &dir.path().join("site"),
1434 &dir.path().join("templates"),
1435 );
1436
1437 let result = plugin.after_compile(&ctx);
1438 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1439 .unwrap();
1440 assert!(result.is_err(), "unreadable site subdir must be an Err");
1441 }
1442
1443 #[test]
1444 #[cfg(unix)]
1445 fn read_frontmatter_for_html_unreadable_sidecar_returns_empty() {
1446 use std::os::unix::fs::PermissionsExt;
1447
1448 let dir = tempdir().unwrap();
1449 let site = dir.path().join("site");
1450 let sidecars = dir.path().join(".meta");
1451 fs::create_dir_all(&site).unwrap();
1452 fs::create_dir_all(&sidecars).unwrap();
1453
1454 let html = site.join("post.html");
1455 fs::write(&html, "").unwrap();
1456 let sidecar = sidecars.join("post.meta.json");
1457 fs::write(&sidecar, r#"{"title": "Hidden"}"#).unwrap();
1458 fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o000))
1459 .unwrap();
1460
1461 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
1462 fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o644))
1463 .unwrap();
1464 assert!(meta.is_empty(), "unreadable sidecar must yield empty map");
1465 }
1466
1467 #[test]
1468 fn test_enrich_related_posts_mixed_shapes_and_tie_break() {
1469 let dir = tempdir().unwrap();
1473 let site = dir.path().join("site");
1474 let sidecars = dir.path().join(".meta");
1475 fs::create_dir_all(&site).unwrap();
1476 fs::create_dir_all(&sidecars).unwrap();
1477
1478 let a = site.join("a.html");
1479 let b = site.join("b.html");
1480 let c = site.join("c.html");
1481 let d = site.join("d.html");
1482 for f in [&a, &b, &c, &d] {
1483 fs::write(f, "").unwrap();
1484 }
1485
1486 fs::write(
1489 sidecars.join("a.meta.json"),
1490 r#"{"title": "A", "date": "2026-01-01", "tags": ["shared", 42], "categories": 7}"#,
1491 )
1492 .unwrap();
1493 fs::write(
1495 sidecars.join("b.meta.json"),
1496 r#"{"title": "B", "date": "2026-01-02", "tags": "shared,,"}"#,
1497 )
1498 .unwrap();
1499 fs::write(
1501 sidecars.join("c.meta.json"),
1502 r#"{"title": "C", "date": "2026-01-03", "tags": ["shared"], "categories": ",misc,"}"#,
1503 )
1504 .unwrap();
1505 fs::write(
1508 sidecars.join("d.meta.json"),
1509 r#"{"title": "D", "date": "2026-01-04", "tags": 99, "categories": ["misc", 5]}"#,
1510 )
1511 .unwrap();
1512
1513 let files = vec![a.clone(), b, c, d.clone()];
1514 let enriched = enrich_with_related_posts(&files, &site, &sidecars);
1515
1516 let a_rel = enriched
1519 .get(&a)
1520 .unwrap()
1521 .get("related_posts")
1522 .unwrap()
1523 .as_array()
1524 .unwrap()
1525 .clone();
1526 assert_eq!(a_rel.len(), 2);
1527 assert_eq!(a_rel[0]["title"], "C");
1528 assert_eq!(a_rel[1]["title"], "B");
1529
1530 let d_rel = enriched
1532 .get(&d)
1533 .unwrap()
1534 .get("related_posts")
1535 .unwrap()
1536 .as_array()
1537 .unwrap()
1538 .clone();
1539 assert_eq!(d_rel.len(), 1);
1540 assert_eq!(d_rel[0]["title"], "C");
1541 }
1542}