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")]
35#[derive(Debug)]
36pub struct TemplatePlugin {
37 config: TemplateConfig,
38}
39
40#[cfg(feature = "templates")]
41impl TemplatePlugin {
42 #[must_use]
55 pub const fn new(config: TemplateConfig) -> Self {
56 Self { config }
57 }
58
59 #[must_use]
73 pub fn from_template_dir(template_dir: &Path) -> Self {
74 Self {
75 config: TemplateConfig {
76 template_dir: template_dir.join("tera"),
77 ..Default::default()
78 },
79 }
80 }
81}
82
83#[cfg(feature = "templates")]
84impl Plugin for TemplatePlugin {
85 fn name(&self) -> &'static str {
86 "templates"
87 }
88
89 fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
90 let sidecar_dir = ctx.build_dir.join(".meta");
92 let count = frontmatter::emit_sidecars(&ctx.content_dir, &sidecar_dir)
93 .map_err(|e| SsgError::io(e, &sidecar_dir))?;
94 if count > 0 {
95 log::info!("[templates] Emitted {count} frontmatter sidecar(s)");
96 }
97 Ok(())
98 }
99
100 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
101 let Some(engine) = TemplateEngine::init(self.config.clone())
102 .map_err(|e| SsgError::io(e, &self.config.template_dir))?
103 else {
104 log::info!(
105 "[templates] No templates at {}, skipping",
106 self.config.template_dir.display()
107 );
108 return Ok(());
109 };
110
111 let mut site_globals = ctx
113 .config
114 .as_ref()
115 .map(TemplateEngine::site_globals_from_config)
116 .unwrap_or_default();
117
118 let data_files = TemplateEngine::load_data_files(&ctx.content_dir);
120 if !data_files.is_empty() {
121 let _ = site_globals.insert(
122 "data".to_string(),
123 serde_json::Value::Object(data_files.into_iter().collect()),
124 );
125 }
126
127 let sidecar_dir = resolve_sidecar_dir(ctx);
128 let html_files = collect_html_files(&ctx.site_dir)?;
129 let enriched_fm_map =
130 enrich_with_related_posts(&html_files, &ctx.site_dir, &sidecar_dir);
131
132 let mut rendered = 0usize;
133 let mut skipped = 0usize;
134 for html_path in &html_files {
135 let content = fs::read_to_string(html_path).with_path(html_path)?;
136
137 let fm =
139 enriched_fm_map.get(html_path).cloned().unwrap_or_else(|| {
140 read_frontmatter_for_html(
141 html_path,
142 &ctx.site_dir,
143 &sidecar_dir,
144 )
145 });
146
147 let layout =
149 fm.get("layout").and_then(|v| v.as_str()).unwrap_or("page");
150 let template_name = format!("{layout}.html");
151
152 if !engine.has_template(&template_name)
158 && !engine.has_template("page.html")
159 {
160 log::warn!(
161 "[templates] No template '{template_name}' (and no page.html fallback) for {}; leaving compiled output untouched",
162 html_path.display()
163 );
164 skipped += 1;
165 continue;
166 }
167
168 match engine.render_page(
169 &template_name,
170 &content,
171 &fm,
172 &site_globals,
173 ) {
174 Ok(output) => {
175 fs::write(html_path, output).with_path(html_path)?;
176 rendered += 1;
177 }
178 Err(e) => {
179 log::warn!(
180 "[templates] Failed to render {}: {e}",
181 html_path.display()
182 );
183 }
184 }
185 }
186
187 if rendered > 0 {
188 log::info!("[templates] Rendered {rendered} page(s)");
189 }
190 if skipped > 0 {
191 log::warn!(
192 "[templates] Skipped {skipped} page(s) with no matching template"
193 );
194 }
195 Ok(())
196 }
197}
198
199#[cfg(feature = "templates")]
214fn resolve_sidecar_dir(ctx: &PluginContext) -> PathBuf {
215 let build_meta = ctx.build_dir.join(".meta");
216 if build_meta.is_dir() {
217 build_meta
218 } else {
219 ctx.site_dir.join(".meta")
220 }
221}
222
223#[cfg(feature = "templates")]
225fn read_frontmatter_for_html(
226 html_path: &Path,
227 site_dir: &Path,
228 sidecar_dir: &Path,
229) -> HashMap<String, serde_json::Value> {
230 let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
231 let sidecar = sidecar_dir.join(rel).with_extension("meta.json");
232 if sidecar.exists() {
233 if let Ok(content) = fs::read_to_string(&sidecar) {
234 if let Ok(meta) = serde_json::from_str(&content) {
235 return meta;
236 }
237 }
238 }
239 HashMap::new()
240}
241
242#[cfg(feature = "templates")]
244fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
245 crate::walk::walk_files_bounded_depth(dir, "html", MAX_DIR_DEPTH)
246}
247
248#[cfg(feature = "templates")]
250fn enrich_with_related_posts(
251 html_files: &[PathBuf],
252 site_dir: &Path,
253 sidecar_dir: &Path,
254) -> HashMap<PathBuf, HashMap<String, serde_json::Value>> {
255 let mut pages_meta = HashMap::new();
256
257 for html_path in html_files {
258 let fm = read_frontmatter_for_html(html_path, site_dir, sidecar_dir);
259 let mut terms = std::collections::HashSet::new();
260
261 if let Some(tags_val) = fm.get("tags") {
262 if let Some(arr) = tags_val.as_array() {
263 for item in arr {
264 if let Some(s) = item.as_str() {
265 let _ = terms.insert(s.to_string());
266 }
267 }
268 } else if let Some(s) = tags_val.as_str() {
269 for term in ssg_core::split_terms(s) {
270 let _ = terms.insert(term);
271 }
272 }
273 }
274 if let Some(cats_val) = fm.get("categories") {
275 if let Some(arr) = cats_val.as_array() {
276 for item in arr {
277 if let Some(s) = item.as_str() {
278 let _ = terms.insert(s.to_string());
279 }
280 }
281 } else if let Some(s) = cats_val.as_str() {
282 for term in ssg_core::split_terms(s) {
283 let _ = terms.insert(term);
284 }
285 }
286 }
287
288 let title = fm
289 .get("title")
290 .and_then(|v| v.as_str())
291 .unwrap_or("Untitled")
292 .to_string();
293 let date = fm
294 .get("date")
295 .and_then(|v| v.as_str())
296 .unwrap_or("")
297 .to_string();
298 let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
299 let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
300
301 let _ =
302 pages_meta.insert(html_path.clone(), (fm, title, terms, date, url));
303 }
304
305 let mut enriched = HashMap::new();
306 for (html_path, (fm, _title, terms, _date, _url)) in &pages_meta {
307 let mut candidates = Vec::new();
308
309 for (
310 other_path,
311 (_other_fm, other_title, other_terms, other_date, other_url),
312 ) in &pages_meta
313 {
314 if other_path == html_path {
315 continue;
316 }
317
318 let overlap = terms.intersection(other_terms).count();
319 if overlap > 0 {
320 candidates.push((
321 overlap,
322 other_date.clone(),
323 other_title.clone(),
324 other_url.clone(),
325 ));
326 }
327 }
328
329 candidates.sort_by(|a, b| {
342 b.0.cmp(&a.0)
343 .then_with(|| b.1.cmp(&a.1))
344 .then_with(|| a.3.cmp(&b.3))
345 });
346
347 let top_3: Vec<serde_json::Value> = candidates
348 .into_iter()
349 .take(3)
350 .map(|(_overlap, other_date, other_title, other_url)| {
351 let mut obj = serde_json::Map::new();
352 let _ = obj.insert(
353 "title".to_string(),
354 serde_json::Value::String(other_title),
355 );
356 let _ = obj.insert(
357 "url".to_string(),
358 serde_json::Value::String(other_url),
359 );
360 let _ = obj.insert(
361 "date".to_string(),
362 serde_json::Value::String(other_date),
363 );
364 serde_json::Value::Object(obj)
365 })
366 .collect();
367
368 let mut new_fm = fm.clone();
369 let _ = new_fm.insert(
370 "related_posts".to_string(),
371 serde_json::Value::Array(top_3),
372 );
373 let _ = enriched.insert(html_path.clone(), new_fm);
374 }
375
376 enriched
377}
378
379#[cfg(all(test, feature = "templates"))]
380mod tests {
381 use super::*;
382 use crate::cmd::SsgConfig;
383 use crate::test_support::init_logger;
384 use std::fs;
385 use tempfile::{tempdir, TempDir};
386
387 fn layout() -> (TempDir, PathBuf, PathBuf, PathBuf, PathBuf) {
392 init_logger();
393 let dir = tempdir().expect("tempdir");
394 let content = dir.path().join("content");
395 let build = dir.path().join("build");
396 let site = dir.path().join("site");
397 let templates = dir.path().join("templates/tera");
398 for d in [&content, &build, &site, &templates] {
399 fs::create_dir_all(d).expect("mkdir");
400 }
401 (dir, content, build, site, templates)
402 }
403
404 fn make_config(root: &Path) -> SsgConfig {
405 SsgConfig {
406 listings: Vec::new(),
407 site_name: "Test".to_string(),
408 site_title: "Test Site".to_string(),
409 site_description: "Desc".to_string(),
410 base_url: "http://localhost".to_string(),
411 language: "en-GB".to_string(),
412 content_dir: root.join("content"),
413 output_dir: root.join("build"),
414 template_dir: root.join("templates"),
415 theme: None,
416 serve_dir: None,
417 #[cfg(feature = "i18n")]
418 i18n: None,
419 cdn_prefix: None,
420 og_image: None,
421 image: crate::cmd::ImageConfig::default(),
422 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
423 agents: None,
424 transitions: false,
425 security: crate::cmd::SecurityConfig::default(),
426 no_taxonomy_pages: false,
427 }
428 }
429
430 fn setup_project(dir: &Path) {
431 let content = dir.join("content");
432 let build = dir.join("build");
433 let site = dir.join("site");
434 let templates = dir.join("templates/tera");
435 fs::create_dir_all(&content).unwrap();
436 fs::create_dir_all(&build).unwrap();
437 fs::create_dir_all(&site).unwrap();
438 fs::create_dir_all(&templates).unwrap();
439
440 fs::write(
441 templates.join("base.html"),
442 r#"<!DOCTYPE html>
443<html><head><title>{{ page.title | default("") }}</title></head>
444<body>{% block content %}{% endblock %}</body></html>"#,
445 )
446 .unwrap();
447
448 fs::write(
449 templates.join("page.html"),
450 r#"{% extends "base.html" %}
451{% block content %}{{ page.content | safe }}{% endblock %}"#,
452 )
453 .unwrap();
454
455 fs::write(
456 content.join("index.md"),
457 "---\ntitle: Home\nlayout: page\n---\n# Welcome\n",
458 )
459 .unwrap();
460
461 fs::write(site.join("index.html"), "<h1>Welcome</h1>").unwrap();
462
463 let meta_dir = build.join(".meta");
464 fs::create_dir_all(&meta_dir).unwrap();
465 fs::write(
466 meta_dir.join("index.meta.json"),
467 r#"{"title": "Home", "layout": "page"}"#,
468 )
469 .unwrap();
470 }
471
472 #[test]
483 fn layout_resolves_when_build_dir_was_promoted_to_site_dir() {
484 init_logger();
485 let dir = tempdir().unwrap();
486 let content = dir.path().join("content");
487 let build = dir.path().join("build");
488 let site = dir.path().join("site");
489 let templates = dir.path().join("templates/tera");
490 for d in [&content, &build, &site, &templates] {
491 fs::create_dir_all(d).unwrap();
492 }
493
494 fs::write(templates.join("page.html"), "FALLBACK-PAGE").unwrap();
496 fs::write(templates.join("custom.html"), "CUSTOM-LAYOUT").unwrap();
497 fs::write(site.join("index.html"), "<h1>compiled</h1>").unwrap();
498
499 let meta_dir = site.join(".meta");
502 fs::create_dir_all(&meta_dir).unwrap();
503 fs::write(
504 meta_dir.join("index.meta.json"),
505 r#"{"title": "Home", "layout": "custom"}"#,
506 )
507 .unwrap();
508 assert!(
509 !build.join(".meta").exists(),
510 "fixture must model the promoted layout"
511 );
512
513 let plugin = TemplatePlugin::new(TemplateConfig {
514 template_dir: templates,
515 ..Default::default()
516 });
517 let config = make_config(dir.path());
518 let ctx = PluginContext::with_config(
519 &content,
520 &build,
521 &site,
522 &dir.path().join("templates"),
523 config,
524 );
525
526 plugin.after_compile(&ctx).unwrap();
527
528 let out = fs::read_to_string(site.join("index.html")).unwrap();
529 assert!(
530 out.contains("CUSTOM-LAYOUT"),
531 "expected the `custom` layout from the sidecar, got: {out}"
532 );
533 assert!(
534 !out.contains("FALLBACK-PAGE"),
535 "must not silently fall back to page.html: {out}"
536 );
537 }
538
539 #[test]
543 fn missing_template_leaves_output_untouched_and_is_not_counted() {
544 init_logger();
545 let dir = tempdir().unwrap();
546 let content = dir.path().join("content");
547 let build = dir.path().join("build");
548 let site = dir.path().join("site");
549 let templates = dir.path().join("templates/tera");
550 for d in [&content, &build, &site, &templates] {
551 fs::create_dir_all(d).unwrap();
552 }
553
554 fs::write(templates.join("other.html"), "OTHER").unwrap();
557 fs::write(site.join("index.html"), "<h1>compiled</h1>").unwrap();
558 let meta_dir = site.join(".meta");
559 fs::create_dir_all(&meta_dir).unwrap();
560 fs::write(meta_dir.join("index.meta.json"), r#"{"layout": "nope"}"#)
561 .unwrap();
562
563 let plugin = TemplatePlugin::new(TemplateConfig {
564 template_dir: templates,
565 ..Default::default()
566 });
567 let config = make_config(dir.path());
568 let ctx = PluginContext::with_config(
569 &content,
570 &build,
571 &site,
572 &dir.path().join("templates"),
573 config,
574 );
575
576 plugin.after_compile(&ctx).unwrap();
577
578 let out = fs::read_to_string(site.join("index.html")).unwrap();
579 assert_eq!(
580 out, "<h1>compiled</h1>",
581 "compiled output must survive untouched"
582 );
583 }
584
585 #[test]
586 fn test_template_plugin_renders() {
587 init_logger();
588 let dir = tempdir().unwrap();
589 setup_project(dir.path());
590
591 let plugin = TemplatePlugin::new(TemplateConfig {
592 template_dir: dir.path().join("templates/tera"),
593 ..Default::default()
594 });
595
596 let config = SsgConfig {
597 listings: Vec::new(),
598 site_name: "Test".to_string(),
599 site_title: "Test Site".to_string(),
600 site_description: "Desc".to_string(),
601 base_url: "http://localhost".to_string(),
602 language: "en-GB".to_string(),
603 content_dir: dir.path().join("content"),
604 output_dir: dir.path().join("build"),
605 template_dir: dir.path().join("templates"),
606 theme: None,
607 serve_dir: None,
608 #[cfg(feature = "i18n")]
609 i18n: None,
610 cdn_prefix: None,
611 og_image: None,
612 image: crate::cmd::ImageConfig::default(),
613 edge_headers: crate::cmd::EdgeHeadersConfig::default(),
614 agents: None,
615 transitions: false,
616 security: crate::cmd::SecurityConfig::default(),
617 no_taxonomy_pages: false,
618 };
619
620 let content_dir = config.content_dir.clone();
621 let output_dir = config.output_dir.clone();
622 let template_dir = config.template_dir.clone();
623 let site = dir.path().join("site");
624 let ctx = PluginContext::with_config(
625 &content_dir,
626 &output_dir,
627 &site,
628 &template_dir,
629 config,
630 );
631
632 plugin.after_compile(&ctx).unwrap();
633
634 let output =
635 fs::read_to_string(dir.path().join("site/index.html")).unwrap();
636 assert!(output.contains("<!DOCTYPE html>"));
637 assert!(output.contains("Home"));
638 assert!(output.contains("<h1>Welcome</h1>"));
639 }
640
641 #[test]
642 fn test_template_plugin_skips_missing_templates() {
643 let dir = tempdir().unwrap();
644 let site = dir.path().join("site");
645 fs::create_dir_all(&site).unwrap();
646 fs::write(site.join("index.html"), "<p>hello</p>").unwrap();
647
648 let plugin = TemplatePlugin::new(TemplateConfig {
649 template_dir: dir.path().join("nonexistent"),
650 ..Default::default()
651 });
652
653 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
654
655 plugin.after_compile(&ctx).unwrap();
656
657 let output = fs::read_to_string(site.join("index.html")).unwrap();
658 assert_eq!(output, "<p>hello</p>");
659 }
660
661 #[test]
662 fn name_returns_templates_identifier() {
663 let plugin = TemplatePlugin::new(TemplateConfig::default());
664 assert_eq!(plugin.name(), "templates");
665 }
666
667 #[test]
668 fn template_plugin_debug_output_mentions_config() {
669 let plugin = TemplatePlugin::new(TemplateConfig::default());
672 let s = format!("{plugin:?}");
673 assert!(s.contains("TemplatePlugin"), "{s}");
674 }
675
676 #[test]
677 fn new_stores_supplied_config() {
678 let cfg = TemplateConfig {
679 template_dir: std::env::temp_dir().join("ssg_template_fake"),
680 ..Default::default()
681 };
682 let plugin = TemplatePlugin::new(cfg.clone());
683 assert_eq!(plugin.config.template_dir, cfg.template_dir);
684 }
685
686 #[test]
687 fn from_template_dir_nests_under_tera_subdirectory() {
688 let plugin =
689 TemplatePlugin::from_template_dir(Path::new("/my/templates"));
690 assert!(plugin.config.template_dir.ends_with("templates/tera"));
691 }
692
693 #[test]
694 fn before_compile_emits_sidecars_from_content_markdown() {
695 let (_tmp, content, build, _site, templates) = layout();
696 fs::write(content.join("index.md"), "---\ntitle: Test\n---\nbody")
697 .unwrap();
698
699 let plugin = TemplatePlugin::new(TemplateConfig {
700 template_dir: templates,
701 ..Default::default()
702 });
703 let ctx = PluginContext::new(&content, &build, &content, &content);
704
705 plugin.before_compile(&ctx).unwrap();
706 assert!(build.join(".meta").join("index.meta.json").exists());
707 }
708
709 #[test]
710 fn before_compile_no_markdown_files_still_returns_ok() {
711 let (_tmp, content, build, _site, templates) = layout();
712 let plugin = TemplatePlugin::new(TemplateConfig {
713 template_dir: templates,
714 ..Default::default()
715 });
716 let ctx = PluginContext::new(&content, &build, &content, &content);
717 plugin.before_compile(&ctx).unwrap();
718 }
719
720 #[test]
721 fn after_compile_without_config_uses_empty_site_globals() {
722 let dir = tempdir().unwrap();
723 setup_project(dir.path());
724
725 let plugin = TemplatePlugin::new(TemplateConfig {
726 template_dir: dir.path().join("templates/tera"),
727 ..Default::default()
728 });
729 let ctx = PluginContext::new(
730 &dir.path().join("content"),
731 &dir.path().join("build"),
732 &dir.path().join("site"),
733 &dir.path().join("templates"),
734 );
735
736 plugin.after_compile(&ctx).unwrap();
737 let output =
738 fs::read_to_string(dir.path().join("site").join("index.html"))
739 .unwrap();
740 assert!(output.contains("<!DOCTYPE html>"));
741 }
742
743 #[test]
744 fn after_compile_loads_data_files_into_context() {
745 let dir = tempdir().unwrap();
746 setup_project(dir.path());
747
748 let data = dir.path().join("data");
749 fs::create_dir_all(&data).unwrap();
750 fs::write(data.join("nav.toml"), r#"site = "demo""#).unwrap();
751
752 let plugin = TemplatePlugin::new(TemplateConfig {
753 template_dir: dir.path().join("templates/tera"),
754 ..Default::default()
755 });
756 let config = make_config(dir.path());
757 let ctx = PluginContext::with_config(
758 &config.content_dir.clone(),
759 &config.output_dir.clone(),
760 &dir.path().join("site"),
761 &config.template_dir.clone(),
762 config,
763 );
764
765 plugin.after_compile(&ctx).unwrap();
766 let output =
767 fs::read_to_string(dir.path().join("site").join("index.html"))
768 .unwrap();
769 assert!(output.contains("<!DOCTYPE html>"));
770 }
771
772 #[test]
773 fn after_compile_unknown_layout_does_not_propagate_error() {
774 let dir = tempdir().unwrap();
775 setup_project(dir.path());
776
777 let meta_dir = dir.path().join("build").join(".meta");
778 fs::write(
779 meta_dir.join("index.meta.json"),
780 r#"{"title": "Home", "layout": "unknown_layout_999"}"#,
781 )
782 .unwrap();
783
784 let plugin = TemplatePlugin::new(TemplateConfig {
785 template_dir: dir.path().join("templates/tera"),
786 ..Default::default()
787 });
788 let ctx = PluginContext::new(
789 &dir.path().join("content"),
790 &dir.path().join("build"),
791 &dir.path().join("site"),
792 &dir.path().join("templates"),
793 );
794
795 plugin
796 .after_compile(&ctx)
797 .expect("render failure must not propagate");
798 }
799
800 #[test]
801 fn after_compile_default_layout_is_page_when_missing_field() {
802 let dir = tempdir().unwrap();
803 setup_project(dir.path());
804
805 let meta_dir = dir.path().join("build").join(".meta");
806 fs::write(meta_dir.join("index.meta.json"), r#"{"title": "Home"}"#)
807 .unwrap();
808
809 let plugin = TemplatePlugin::new(TemplateConfig {
810 template_dir: dir.path().join("templates/tera"),
811 ..Default::default()
812 });
813 let ctx = PluginContext::new(
814 &dir.path().join("content"),
815 &dir.path().join("build"),
816 &dir.path().join("site"),
817 &dir.path().join("templates"),
818 );
819
820 plugin.after_compile(&ctx).unwrap();
821 let out =
822 fs::read_to_string(dir.path().join("site").join("index.html"))
823 .unwrap();
824 assert!(out.contains("<!DOCTYPE html>"));
825 }
826
827 #[test]
832 fn read_frontmatter_for_html_direct_sidecar_match() {
833 let dir = tempdir().unwrap();
834 let site = dir.path().join("site");
835 let sidecars = dir.path().join(".meta");
836 fs::create_dir_all(&site).unwrap();
837 fs::create_dir_all(&sidecars).unwrap();
838
839 let html = site.join("post.html");
840 fs::write(&html, "").unwrap();
841 fs::write(sidecars.join("post.meta.json"), r#"{"title": "Direct"}"#)
842 .unwrap();
843
844 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
845 assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("Direct"));
846 }
847
848 #[test]
849 fn read_frontmatter_for_html_invalid_sidecar_returns_empty() {
850 let dir = tempdir().unwrap();
851 let site = dir.path().join("site");
852 let sidecars = dir.path().join(".meta");
853 fs::create_dir_all(&site).unwrap();
854 fs::create_dir_all(&sidecars).unwrap();
855
856 let html = site.join("post.html");
857 fs::write(&html, "").unwrap();
858 fs::write(sidecars.join("post.meta.json"), "{not valid").unwrap();
859
860 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
861 assert!(meta.is_empty());
862 }
863
864 #[test]
865 fn read_frontmatter_for_html_no_match_returns_empty_map() {
866 let dir = tempdir().unwrap();
867 let site = dir.path().join("site");
868 let sidecars = dir.path().join(".meta");
869 fs::create_dir_all(&site).unwrap();
870 fs::create_dir_all(&sidecars).unwrap();
871
872 let html = site.join("ghost.html");
873 fs::write(&html, "").unwrap();
874
875 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
876 assert!(meta.is_empty());
877 }
878
879 #[test]
880 fn read_frontmatter_for_html_path_outside_site_dir_uses_full_path_as_rel() {
881 let dir = tempdir().unwrap();
888 let site = dir.path().join("site");
889 let elsewhere = dir.path().join("elsewhere");
890 let sidecars = dir.path().join(".meta");
891 fs::create_dir_all(&site).unwrap();
892 fs::create_dir_all(&elsewhere).unwrap();
893 fs::create_dir_all(&sidecars).unwrap();
894
895 let html = elsewhere.join("post.html");
896 fs::write(&html, "").unwrap();
897
898 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
899 assert!(
900 meta.is_empty(),
901 "no sidecar exists for the full out-of-tree path"
902 );
903 }
904
905 #[test]
910 fn collect_html_files_filters_non_html_extensions() {
911 let dir = tempdir().unwrap();
912 fs::write(dir.path().join("a.html"), "").unwrap();
913 fs::write(dir.path().join("b.css"), "").unwrap();
914 fs::write(dir.path().join("c.js"), "").unwrap();
915
916 let files = collect_html_files(dir.path()).unwrap();
917 assert_eq!(files.len(), 1);
918 }
919
920 #[test]
921 fn collect_html_files_recurses_into_subdirectories() {
922 let dir = tempdir().unwrap();
923 let nested = dir.path().join("blog").join("2026");
924 fs::create_dir_all(&nested).unwrap();
925 fs::write(dir.path().join("index.html"), "").unwrap();
926 fs::write(nested.join("post.html"), "").unwrap();
927
928 let files = collect_html_files(dir.path()).unwrap();
929 assert_eq!(files.len(), 2);
930 }
931
932 #[test]
933 fn collect_html_files_returns_empty_for_missing_directory() {
934 let dir = tempdir().unwrap();
935 let result = collect_html_files(&dir.path().join("missing")).unwrap();
936 assert!(result.is_empty());
937 }
938
939 #[test]
940 fn test_template_plugin_from_template_dir() {
941 let p = TemplatePlugin::from_template_dir(Path::new("t"));
942 assert_eq!(p.name(), "templates");
943 }
944
945 #[test]
946 fn test_template_plugin_before_compile_error() {
947 let dir = tempdir().unwrap();
948 let content_dir = dir.path().join("content");
949 fs::create_dir_all(&content_dir).unwrap();
950 fs::write(content_dir.join("index.md"), "---\ntitle: test\n---\n")
951 .unwrap();
952
953 let build_file = dir.path().join("build_file");
954 fs::write(&build_file, "").unwrap();
955
956 let ctx = PluginContext::new(
957 &content_dir,
958 &build_file,
959 dir.path(),
960 dir.path(),
961 );
962 let res =
963 TemplatePlugin::new(TemplateConfig::default()).before_compile(&ctx);
964 assert!(res.is_err());
965 }
966
967 #[test]
968 #[cfg(unix)]
969 fn test_template_plugin_after_compile_read_html_error() {
970 let dir = tempdir().unwrap();
971 let site_dir = dir.path().join("site");
972 fs::create_dir_all(&site_dir).unwrap();
973 let html_path = site_dir.join("test.html");
974 fs::write(&html_path, "test").unwrap();
975
976 #[cfg(unix)]
977 {
978 use std::os::unix::fs::PermissionsExt;
979 fs::set_permissions(&html_path, fs::Permissions::from_mode(0o000))
980 .unwrap();
981 }
982
983 let templates = dir.path().join("tera");
984 fs::create_dir_all(&templates).unwrap();
985 fs::write(templates.join("base.html"), "").unwrap();
986
987 let ctx =
988 PluginContext::new(dir.path(), dir.path(), &site_dir, &templates);
989 let plugin = TemplatePlugin::new(TemplateConfig {
990 template_dir: templates,
991 ..Default::default()
992 });
993 let res = plugin.after_compile(&ctx);
994 #[cfg(unix)]
995 {
996 use std::os::unix::fs::PermissionsExt;
997 let _ = fs::set_permissions(
998 &html_path,
999 fs::Permissions::from_mode(0o644),
1000 );
1001 }
1002 #[cfg(unix)]
1003 assert!(res.is_err());
1004 }
1005
1006 #[test]
1007 fn test_enrich_with_related_posts_direct() {
1008 let dir = tempdir().unwrap();
1009 let site = dir.path().join("site");
1010 let sidecars = dir.path().join(".meta");
1011 fs::create_dir_all(&site).unwrap();
1012 fs::create_dir_all(&sidecars).unwrap();
1013
1014 let html1 = site.join("p1.html");
1015 let html2 = site.join("p2.html");
1016 let html3 = site.join("p3.html");
1017 fs::write(&html1, "").unwrap();
1018 fs::write(&html2, "").unwrap();
1019 fs::write(&html3, "").unwrap();
1020
1021 fs::write(sidecars.join("p1.meta.json"), r#"{"title": "P1", "date": "2026-06-01", "tags": ["rust", "web"], "categories": "coding,tech"}"#).unwrap();
1023 fs::write(sidecars.join("p2.meta.json"), r#"{"title": "P2", "date": "2026-06-02", "tags": "rust,systems", "categories": ["coding"]}"#).unwrap();
1025 fs::write(
1027 sidecars.join("p3.meta.json"),
1028 r#"{"title": "P3", "date": "2026-06-03", "tags": ["other"]}"#,
1029 )
1030 .unwrap();
1031
1032 let files = vec![html1.clone(), html2.clone(), html3.clone()];
1033 let enriched = enrich_with_related_posts(&files, &site, &sidecars);
1034
1035 let p1_fm = enriched.get(&html1).unwrap();
1036 let related_p1 =
1037 p1_fm.get("related_posts").unwrap().as_array().unwrap();
1038 assert_eq!(related_p1.len(), 1);
1039 assert_eq!(related_p1[0]["title"], "P2");
1040 assert_eq!(related_p1[0]["url"], "/p2.html");
1041 assert_eq!(related_p1[0]["date"], "2026-06-02");
1042
1043 let p3_fm = enriched.get(&html3).unwrap();
1044 let related_p3 =
1045 p3_fm.get("related_posts").unwrap().as_array().unwrap();
1046 assert!(related_p3.is_empty());
1047 }
1048
1049 #[test]
1050 fn enrich_with_related_posts_path_outside_site_dir_uses_full_path_as_rel() {
1051 let dir = tempdir().unwrap();
1057 let site = dir.path().join("site");
1058 let elsewhere = dir.path().join("elsewhere");
1059 let sidecars = dir.path().join(".meta");
1060 fs::create_dir_all(&site).unwrap();
1061 fs::create_dir_all(&elsewhere).unwrap();
1062 fs::create_dir_all(&sidecars).unwrap();
1063
1064 let html = elsewhere.join("post.html");
1065 fs::write(&html, "").unwrap();
1066
1067 let files = vec![html.clone()];
1068 let enriched = enrich_with_related_posts(&files, &site, &sidecars);
1069 let fm = enriched.get(&html).unwrap();
1070 assert!(fm
1073 .get("related_posts")
1074 .unwrap()
1075 .as_array()
1076 .unwrap()
1077 .is_empty());
1078 }
1079
1080 #[test]
1081 fn after_compile_render_failure_logs_and_leaves_file_untouched() {
1082 init_logger();
1085 let dir = tempdir().unwrap();
1086 setup_project(dir.path());
1087 fs::write(
1088 dir.path().join("templates/tera/page.html"),
1089 r#"{% include "missing-partial.html" %}"#,
1090 )
1091 .unwrap();
1092
1093 let plugin = TemplatePlugin::new(TemplateConfig {
1094 template_dir: dir.path().join("templates/tera"),
1095 ..Default::default()
1096 });
1097 let ctx = PluginContext::new(
1098 &dir.path().join("content"),
1099 &dir.path().join("build"),
1100 &dir.path().join("site"),
1101 &dir.path().join("templates"),
1102 );
1103
1104 plugin
1105 .after_compile(&ctx)
1106 .expect("render failure must not propagate");
1107 let out =
1108 fs::read_to_string(dir.path().join("site").join("index.html"))
1109 .unwrap();
1110 assert_eq!(out, "<h1>Welcome</h1>", "failed render must not rewrite");
1111 }
1112
1113 #[test]
1114 #[cfg(unix)]
1115 fn after_compile_write_failure_propagates() {
1116 use std::os::unix::fs::PermissionsExt;
1117
1118 let dir = tempdir().unwrap();
1121 setup_project(dir.path());
1122 let html = dir.path().join("site").join("index.html");
1123 fs::set_permissions(&html, fs::Permissions::from_mode(0o444)).unwrap();
1124
1125 let plugin = TemplatePlugin::new(TemplateConfig {
1126 template_dir: dir.path().join("templates/tera"),
1127 ..Default::default()
1128 });
1129 let ctx = PluginContext::new(
1130 &dir.path().join("content"),
1131 &dir.path().join("build"),
1132 &dir.path().join("site"),
1133 &dir.path().join("templates"),
1134 );
1135
1136 let result = plugin.after_compile(&ctx);
1137 let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1138 assert!(result.is_err(), "read-only output must surface as Err");
1139 }
1140
1141 #[test]
1142 #[cfg(unix)]
1143 fn after_compile_propagates_walk_error_from_unreadable_site_subdir() {
1144 use std::os::unix::fs::PermissionsExt;
1145
1146 let dir = tempdir().unwrap();
1147 setup_project(dir.path());
1148 let locked = dir.path().join("site").join("locked");
1149 fs::create_dir_all(&locked).unwrap();
1150 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1151 .unwrap();
1152
1153 let plugin = TemplatePlugin::new(TemplateConfig {
1154 template_dir: dir.path().join("templates/tera"),
1155 ..Default::default()
1156 });
1157 let ctx = PluginContext::new(
1158 &dir.path().join("content"),
1159 &dir.path().join("build"),
1160 &dir.path().join("site"),
1161 &dir.path().join("templates"),
1162 );
1163
1164 let result = plugin.after_compile(&ctx);
1165 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755))
1166 .unwrap();
1167 assert!(result.is_err(), "unreadable site subdir must be an Err");
1168 }
1169
1170 #[test]
1171 #[cfg(unix)]
1172 fn read_frontmatter_for_html_unreadable_sidecar_returns_empty() {
1173 use std::os::unix::fs::PermissionsExt;
1174
1175 let dir = tempdir().unwrap();
1176 let site = dir.path().join("site");
1177 let sidecars = dir.path().join(".meta");
1178 fs::create_dir_all(&site).unwrap();
1179 fs::create_dir_all(&sidecars).unwrap();
1180
1181 let html = site.join("post.html");
1182 fs::write(&html, "").unwrap();
1183 let sidecar = sidecars.join("post.meta.json");
1184 fs::write(&sidecar, r#"{"title": "Hidden"}"#).unwrap();
1185 fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o000))
1186 .unwrap();
1187
1188 let meta = read_frontmatter_for_html(&html, &site, &sidecars);
1189 fs::set_permissions(&sidecar, fs::Permissions::from_mode(0o644))
1190 .unwrap();
1191 assert!(meta.is_empty(), "unreadable sidecar must yield empty map");
1192 }
1193
1194 #[test]
1195 fn test_enrich_related_posts_mixed_shapes_and_tie_break() {
1196 let dir = tempdir().unwrap();
1200 let site = dir.path().join("site");
1201 let sidecars = dir.path().join(".meta");
1202 fs::create_dir_all(&site).unwrap();
1203 fs::create_dir_all(&sidecars).unwrap();
1204
1205 let a = site.join("a.html");
1206 let b = site.join("b.html");
1207 let c = site.join("c.html");
1208 let d = site.join("d.html");
1209 for f in [&a, &b, &c, &d] {
1210 fs::write(f, "").unwrap();
1211 }
1212
1213 fs::write(
1216 sidecars.join("a.meta.json"),
1217 r#"{"title": "A", "date": "2026-01-01", "tags": ["shared", 42], "categories": 7}"#,
1218 )
1219 .unwrap();
1220 fs::write(
1222 sidecars.join("b.meta.json"),
1223 r#"{"title": "B", "date": "2026-01-02", "tags": "shared,,"}"#,
1224 )
1225 .unwrap();
1226 fs::write(
1228 sidecars.join("c.meta.json"),
1229 r#"{"title": "C", "date": "2026-01-03", "tags": ["shared"], "categories": ",misc,"}"#,
1230 )
1231 .unwrap();
1232 fs::write(
1235 sidecars.join("d.meta.json"),
1236 r#"{"title": "D", "date": "2026-01-04", "tags": 99, "categories": ["misc", 5]}"#,
1237 )
1238 .unwrap();
1239
1240 let files = vec![a.clone(), b, c, d.clone()];
1241 let enriched = enrich_with_related_posts(&files, &site, &sidecars);
1242
1243 let a_rel = enriched
1246 .get(&a)
1247 .unwrap()
1248 .get("related_posts")
1249 .unwrap()
1250 .as_array()
1251 .unwrap()
1252 .clone();
1253 assert_eq!(a_rel.len(), 2);
1254 assert_eq!(a_rel[0]["title"], "C");
1255 assert_eq!(a_rel[1]["title"], "B");
1256
1257 let d_rel = enriched
1259 .get(&d)
1260 .unwrap()
1261 .get("related_posts")
1262 .unwrap()
1263 .as_array()
1264 .unwrap()
1265 .clone();
1266 assert_eq!(d_rel.len(), 1);
1267 assert_eq!(d_rel[0]["title"], "C");
1268 }
1269}