Skip to main content

ssg/plugins/
template_plugin.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Template rendering plugin.
5//!
6//! Post-processes compiled HTML through templates, enabling
7//! template inheritance, conditionals, loops, and filters.
8
9#[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/// Plugin that post-processes compiled HTML through templates.
26///
27/// Runs in the `after_compile` phase. For each HTML file in `site_dir`:
28/// 1. Reads the companion `.meta.json` sidecar (from frontmatter extraction)
29/// 2. Determines the layout from frontmatter (`layout` field, default: `page`)
30/// 3. Renders the HTML through the template chain
31/// 4. Writes the rendered result back to the same file
32///
33/// Falls back gracefully if no templates directory exists.
34#[cfg(feature = "templates")]
35#[derive(Debug)]
36pub struct TemplatePlugin {
37    config: TemplateConfig,
38}
39
40#[cfg(feature = "templates")]
41impl TemplatePlugin {
42    /// Creates a new `TemplatePlugin` with the given configuration.
43    ///
44    /// # Examples
45    ///
46    /// ```rust
47    /// use ssg::plugin::Plugin;
48    /// use ssg::template_engine::TemplateConfig;
49    /// use ssg::template_plugin::TemplatePlugin;
50    ///
51    /// let p = TemplatePlugin::new(TemplateConfig::default());
52    /// assert_eq!(p.name(), "templates");
53    /// ```
54    #[must_use]
55    pub const fn new(config: TemplateConfig) -> Self {
56        Self { config }
57    }
58
59    /// Creates a `TemplatePlugin` that looks for templates in the standard
60    /// `templates/tera/` subdirectory of the template dir.
61    ///
62    /// # Examples
63    ///
64    /// ```rust
65    /// use ssg::plugin::Plugin;
66    /// use ssg::template_plugin::TemplatePlugin;
67    /// use std::path::Path;
68    ///
69    /// let p = TemplatePlugin::from_template_dir(Path::new("templates"));
70    /// assert_eq!(p.name(), "templates");
71    /// ```
72    #[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        // Emit .meta.json sidecars for all markdown content
91        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        // Build site-level globals from config
112        let mut site_globals = ctx
113            .config
114            .as_ref()
115            .map(TemplateEngine::site_globals_from_config)
116            .unwrap_or_default();
117
118        // Load data files (data/*.toml, data/*.json) into context
119        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            // Read frontmatter sidecar (enriched with related posts)
138            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            // Determine template from `layout` field
148            let layout =
149                fm.get("layout").and_then(|v| v.as_str()).unwrap_or("page");
150            let template_name = format!("{layout}.html");
151
152            // Resolving to neither the requested layout nor the
153            // `page.html` fallback means `render_page` would hand the
154            // input straight back. Counting that as a render made a
155            // no-op pipeline indistinguishable from a working one, so
156            // report it instead of inflating the success tally.
157            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/// Resolves the directory holding the `.meta.json` frontmatter sidecars.
200///
201/// `before_compile` writes the sidecars under `<build_dir>/.meta`, but
202/// `staticdatagen` *promotes* the build directory onto `site_dir` when
203/// the two differ (the default `output.build-tmp` → `output` case), and
204/// the sidecars travel with it. Reading `<build_dir>/.meta`
205/// unconditionally therefore missed every sidecar on a default build:
206/// `layout` silently fell back to `page` for every page, so a theme
207/// whose layouts were `index`/`about`/`contact` rendered through none of
208/// them — and, absent a `page.html`, through nothing at all.
209///
210/// Prefer the build directory (it still exists for `--serve` builds,
211/// where `site_dir != output_dir` and no promotion happens) and fall
212/// back to the site directory once the promotion has taken place.
213#[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/// Reads frontmatter for an HTML file, trying sidecar then falling back to empty.
224#[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/// Recursively collects `.html` files (delegates to `crate::walk`).
243#[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/// Natively calculates top-3 related articles for each page based on overlapping tags/categories.
249#[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        // Overlap desc, then date desc, then URL asc. The URL tiebreak is
330        // what makes this a total order: without it, two posts sharing an
331        // overlap count and a date kept whatever order the content walk
332        // produced, and `.take(3)` below then selected a different three
333        // depending on the filesystem. APFS and ext4 enumerate a
334        // directory differently, so the same content produced different
335        // "related posts" on macOS and Linux.
336        //
337        // `determinism.yml` could not catch this: it compares two builds
338        // on one runner, and two builds on one filesystem agree. It
339        // surfaced when the golden suite compared a macOS-seeded run
340        // against Linux. URLs are unique per page, so this is total.
341        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    // -------------------------------------------------------------------
388    // Test fixtures
389    // -------------------------------------------------------------------
390
391    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    /// Regression: the `layout` field must still resolve after the
473    /// build directory has been promoted onto `site_dir`.
474    ///
475    /// `before_compile` writes sidecars under `<build_dir>/.meta`, but
476    /// `staticdatagen` renames `output.build-tmp` → `output` once the
477    /// compile finishes, taking `.meta/` with it. Reading
478    /// `<build_dir>/.meta` unconditionally therefore found nothing on
479    /// every default build, so `layout` fell back to `page` for every
480    /// page and a theme with `index`/`about`/`contact` layouts rendered
481    /// through none of them.
482    #[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        // Two distinct templates so the assertion can tell which one ran.
495        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        // The promotion already happened: `.meta` lives beside the
500        // compiled HTML in `site_dir`, and `build_dir/.meta` is absent.
501        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    /// A layout with no template and no `page.html` fallback must be
540    /// reported rather than counted as a successful render — the
541    /// pass-through arm previously made a no-op pipeline look healthy.
542    #[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        // A template dir that exists but holds no `page.html` and no
555        // template matching the requested layout.
556        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        // The derived `Debug` impl is otherwise never exercised (no
670        // test formats the plugin with `{:?}`).
671        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    // -------------------------------------------------------------------
828    // read_frontmatter_for_html — three branches
829    // -------------------------------------------------------------------
830
831    #[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        // `html_path.strip_prefix(site_dir).unwrap_or(html_path)`: when
882        // the page path isn't actually rooted under `site_dir` (e.g. a
883        // symlinked or otherwise out-of-tree path), `strip_prefix` fails
884        // and the fallback treats the whole path as `rel`. Every other
885        // test in this file passes an in-tree path, so this branch was
886        // otherwise never taken.
887        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    // -------------------------------------------------------------------
906    // collect_html_files
907    // -------------------------------------------------------------------
908
909    #[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        // p1: tags as array, categories as string
1022        fs::write(sidecars.join("p1.meta.json"), r#"{"title": "P1", "date": "2026-06-01", "tags": ["rust", "web"], "categories": "coding,tech"}"#).unwrap();
1023        // p2: tags as string (with overlapping "rust"), categories as array
1024        fs::write(sidecars.join("p2.meta.json"), r#"{"title": "P2", "date": "2026-06-02", "tags": "rust,systems", "categories": ["coding"]}"#).unwrap();
1025        // p3: no overlap
1026        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        // Same `strip_prefix(site_dir).unwrap_or(html_path)` fallback as
1052        // `read_frontmatter_for_html`, but in `enrich_with_related_posts`'s
1053        // own URL-building code — a second, independent occurrence of
1054        // the same pattern that every other enrich test bypasses by
1055        // always passing in-tree paths.
1056        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        // Solo page, no overlap possible — just confirm it didn't panic
1071        // building the URL from the un-strippable full path.
1072        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        // A template that parses fine but fails at render time (missing
1083        // include) must hit the warn branch without propagating.
1084        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        // Read + render succeed; writing the rendered output back to a
1119        // read-only file must surface as Err.
1120        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        // Exercises the non-string array items, empty comma segments,
1197        // non-array/non-string tag+category values, and the sort
1198        // comparator (including the equal-overlap date tie-break).
1199        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        // a: non-string tag item ignored; categories is neither array
1214        //    nor string (number) and contributes nothing.
1215        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        // b: tags as string with empty segments (skipped).
1221        fs::write(
1222            sidecars.join("b.meta.json"),
1223            r#"{"title": "B", "date": "2026-01-02", "tags": "shared,,"}"#,
1224        )
1225        .unwrap();
1226        // c: categories as string with leading/trailing empty segments.
1227        fs::write(
1228            sidecars.join("c.meta.json"),
1229            r#"{"title": "C", "date": "2026-01-03", "tags": ["shared"], "categories": ",misc,"}"#,
1230        )
1231        .unwrap();
1232        // d: tags is neither array nor string; categories array with a
1233        //    non-string item.
1234        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        // a shares "shared" with b and c equally; the tie-break sorts
1244        // the newer date (C) first.
1245        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        // d only shares "misc" with c.
1258        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}