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, with the `MiniJinja` templates
28/// under `<template_dir>/tera` of the build context. For each HTML file
29/// in `site_dir`:
30/// 1. Reads the companion `.meta.json` sidecar (from frontmatter extraction)
31/// 2. Determines the layout from frontmatter (`layout` field, default: `page`)
32/// 3. Takes the page's rendered content out of the compiled document
33///    (the inside of `<main>`) and renders it through the template chain
34/// 4. Writes the rendered result back to the same file
35///
36/// Falls back gracefully if no templates directory exists. The
37/// `template_dir` of a [`TemplateConfig`] passed to [`Self::new`] does
38/// not override the context's: it only carries the globals and escaping
39/// settings.
40#[cfg(feature = "templates")]
41#[derive(Debug)]
42pub struct TemplatePlugin {
43    config: TemplateConfig,
44}
45
46#[cfg(feature = "templates")]
47impl TemplatePlugin {
48    /// Creates a new `TemplatePlugin` with the given configuration.
49    ///
50    /// # Examples
51    ///
52    /// ```rust
53    /// use ssg::plugin::Plugin;
54    /// use ssg::template_engine::TemplateConfig;
55    /// use ssg::template_plugin::TemplatePlugin;
56    ///
57    /// let p = TemplatePlugin::new(TemplateConfig::default());
58    /// assert_eq!(p.name(), "templates");
59    /// ```
60    #[must_use]
61    pub const fn new(config: TemplateConfig) -> Self {
62        Self { config }
63    }
64
65    /// Creates a `TemplatePlugin` that looks for templates in the standard
66    /// `templates/tera/` subdirectory of the template dir.
67    ///
68    /// # Examples
69    ///
70    /// ```rust
71    /// use ssg::plugin::Plugin;
72    /// use ssg::template_plugin::TemplatePlugin;
73    /// use std::path::Path;
74    ///
75    /// let p = TemplatePlugin::from_template_dir(Path::new("templates"));
76    /// assert_eq!(p.name(), "templates");
77    /// ```
78    #[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        // Emit .meta.json sidecars for all markdown content
97        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        // The templates of the build at hand: `<ctx.template_dir>/tera`,
108        // as the taxonomy plugin resolves them. The registration-time
109        // config is not consulted for the directory: with the default
110        // config that is `templates/tera` relative to the working
111        // directory, which let a library caller (the golden harness)
112        // render every bundled example through this repository's own
113        // site templates.
114        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        // Build site-level globals from config
130        let mut site_globals = ctx
131            .config
132            .as_ref()
133            .map(TemplateEngine::site_globals_from_config)
134            .unwrap_or_default();
135
136        // Load data files (data/*.toml, data/*.json) into context
137        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            // Read frontmatter sidecar (enriched with related posts)
156            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            // Determine template from `layout` field
166            let layout =
167                fm.get("layout").and_then(|v| v.as_str()).unwrap_or("page");
168            let template_name = format!("{layout}.html");
169
170            // Resolving to neither the requested layout nor the
171            // `page.html` fallback means `render_page` would hand the
172            // input straight back. Counting that as a render made a
173            // no-op pipeline indistinguishable from a working one, so
174            // report it instead of inflating the success tally.
175            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/// Resolves the directory holding the `.meta.json` frontmatter sidecars.
218///
219/// `before_compile` writes the sidecars under `<build_dir>/.meta`, but
220/// `staticdatagen` *promotes* the build directory onto `site_dir` when
221/// the two differ (the default `output.build-tmp` → `output` case), and
222/// the sidecars travel with it. Reading `<build_dir>/.meta`
223/// unconditionally therefore missed every sidecar on a default build:
224/// `layout` silently fell back to `page` for every page, so a theme
225/// whose layouts were `index`/`about`/`contact` rendered through none of
226/// them — and, absent a `page.html`, through nothing at all.
227///
228/// Prefer the build directory (it still exists for `--serve` builds,
229/// where `site_dir != output_dir` and no promotion happens) and fall
230/// back to the site directory once the promotion has taken place.
231#[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/// Reads frontmatter for an HTML file, trying sidecar then falling back to empty.
242#[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    // `emit_sidecars` names a sidecar after the content file
250    // (`about.md` -> `about.meta.json`), while the compile stage writes
251    // that page as `about/index.html`. So an `index.html` below the root
252    // also looks for its directory's sidecar.
253    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/// The rendered content of a compiled page, for `page.content`.
271///
272/// The compile stage writes a complete document: a `StaticWeaver` layout
273/// around the rendered Markdown. The `MiniJinja` layout supplies its own
274/// `<html>`, `<head>` and `<main>` (docs/guide/templates.md), so it must
275/// receive only the content, which is the inside of `<main>`, or of
276/// `<body>` when a template has no `<main>`. Handing it the whole
277/// document nested one page inside another: two `<!DOCTYPE>`s, the
278/// layout's empty `<title>` over the real one, and "Page has 2 <main>
279/// elements" from ssg's own wcag audit. A fragment comes back unchanged.
280#[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/// The text between the first `<name …>` and the last `</name>` after
288/// it, matched case-insensitively. `<name` must be followed by `>` or
289/// whitespace, so `<main` does not match `<mainframe>`.
290#[cfg(feature = "templates")]
291fn element_inner<'a>(html: &'a str, name: &str) -> Option<&'a str> {
292    // ASCII lowering keeps every byte offset, so indices into `lower`
293    // index `html` too.
294    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/// Recursively collects `.html` files (delegates to `crate::walk`).
312#[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/// Natively calculates top-3 related articles for each page based on overlapping tags/categories.
318#[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        // Overlap desc, then date desc, then URL asc. The URL tiebreak is
399        // what makes this a total order: without it, two posts sharing an
400        // overlap count and a date kept whatever order the content walk
401        // produced, and `.take(3)` below then selected a different three
402        // depending on the filesystem. APFS and ext4 enumerate a
403        // directory differently, so the same content produced different
404        // "related posts" on macOS and Linux.
405        //
406        // `determinism.yml` could not catch this: it compares two builds
407        // on one runner, and two builds on one filesystem agree. It
408        // surfaced when the golden suite compared a macOS-seeded run
409        // against Linux. URLs are unique per page, so this is total.
410        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    // -------------------------------------------------------------------
457    // Test fixtures
458    // -------------------------------------------------------------------
459
460    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    /// Regression: the `layout` field must still resolve after the
542    /// build directory has been promoted onto `site_dir`.
543    ///
544    /// `before_compile` writes sidecars under `<build_dir>/.meta`, but
545    /// `staticdatagen` renames `output.build-tmp` → `output` once the
546    /// compile finishes, taking `.meta/` with it. Reading
547    /// `<build_dir>/.meta` unconditionally therefore found nothing on
548    /// every default build, so `layout` fell back to `page` for every
549    /// page and a theme with `index`/`about`/`contact` layouts rendered
550    /// through none of them.
551    #[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        // Two distinct templates so the assertion can tell which one ran.
564        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        // The promotion already happened: `.meta` lives beside the
569        // compiled HTML in `site_dir`, and `build_dir/.meta` is absent.
570        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    /// A layout with no template and no `page.html` fallback must be
609    /// reported rather than counted as a successful render — the
610    /// pass-through arm previously made a no-op pipeline look healthy.
611    #[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        // A template dir that exists but holds no `page.html` and no
624        // template matching the requested layout.
625        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        // The derived `Debug` impl is otherwise never exercised (no
739        // test formats the plugin with `{:?}`).
740        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    /// The plugin renders with the templates of the build it is part of:
897    /// `<ctx.template_dir>/tera`, as the taxonomy plugin resolves them.
898    /// Reading the registration-time config instead let a library caller
899    /// with the default config (`templates`, relative to the working
900    /// directory) pick up an unrelated directory: the golden harness
901    /// rendered every bundled example through this repository's own site
902    /// templates.
903    #[test]
904    fn after_compile_reads_tera_under_the_context_template_dir() {
905        let dir = tempdir().unwrap();
906        setup_project(dir.path());
907        // Somewhere else entirely, with its own MiniJinja set.
908        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    // -------------------------------------------------------------------
940    // page_fragment — the compile stage hands over whole documents
941    // -------------------------------------------------------------------
942
943    /// What the compile stage writes to `site/`: a complete document from
944    /// a `StaticWeaver` template, with the rendered Markdown inside `<main>`.
945    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    /// Regression: the plugin rendered the compile stage's whole document
997    /// as `page.content`, so every page carried a second `<!DOCTYPE>`,
998    /// `<html>`, `<head>` and `<main>` inside the layout's `<main>`. The
999    /// layout's own `<title>` won and the real one was buried; ssg's wcag
1000    /// audit reported "Page has 2 <main> elements" on every page of every
1001    /// bundled example. `page.content` is the rendered content, as
1002    /// docs/guide/templates.md says.
1003    #[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    // -------------------------------------------------------------------
1046    // read_frontmatter_for_html — three branches
1047    // -------------------------------------------------------------------
1048
1049    #[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    /// Regression: the compile stage writes `about.md` as `about/index.html`
1067    /// while `emit_sidecars` writes `about.meta.json`, so the lookup for
1068    /// `about/index.meta.json` found nothing. Every page below the root
1069    /// reached the layout with no front matter: no `page.title`, and the
1070    /// `page.html` fallback whatever `layout:` said.
1071    #[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        // The same one level down, where the sidecar keeps the content tree.
1090        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        // A directory's own index keeps its direct sidecar.
1105        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        // `html_path.strip_prefix(site_dir).unwrap_or(html_path)`: when
1150        // the page path isn't actually rooted under `site_dir` (e.g. a
1151        // symlinked or otherwise out-of-tree path), `strip_prefix` fails
1152        // and the fallback treats the whole path as `rel`. Every other
1153        // test in this file passes an in-tree path, so this branch was
1154        // otherwise never taken.
1155        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    // -------------------------------------------------------------------
1174    // collect_html_files
1175    // -------------------------------------------------------------------
1176
1177    #[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        // The MiniJinja set is `<template_dir>/tera`, so the context's
1256        // template directory is `dir`, not `dir/tera`.
1257        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        // p1: tags as array, categories as string
1292        fs::write(sidecars.join("p1.meta.json"), r#"{"title": "P1", "date": "2026-06-01", "tags": ["rust", "web"], "categories": "coding,tech"}"#).unwrap();
1293        // p2: tags as string (with overlapping "rust"), categories as array
1294        fs::write(sidecars.join("p2.meta.json"), r#"{"title": "P2", "date": "2026-06-02", "tags": "rust,systems", "categories": ["coding"]}"#).unwrap();
1295        // p3: no overlap
1296        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        // Same `strip_prefix(site_dir).unwrap_or(html_path)` fallback as
1322        // `read_frontmatter_for_html`, but in `enrich_with_related_posts`'s
1323        // own URL-building code — a second, independent occurrence of
1324        // the same pattern that every other enrich test bypasses by
1325        // always passing in-tree paths.
1326        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        // Solo page, no overlap possible — just confirm it didn't panic
1341        // building the URL from the un-strippable full path.
1342        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        // A template that parses fine but fails at render time (missing
1356        // include) must hit the warn branch without propagating.
1357        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        // Read + render succeed; writing the rendered output back to a
1392        // read-only file must surface as Err.
1393        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        // Exercises the non-string array items, empty comma segments,
1470        // non-array/non-string tag+category values, and the sort
1471        // comparator (including the equal-overlap date tie-break).
1472        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        // a: non-string tag item ignored; categories is neither array
1487        //    nor string (number) and contributes nothing.
1488        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        // b: tags as string with empty segments (skipped).
1494        fs::write(
1495            sidecars.join("b.meta.json"),
1496            r#"{"title": "B", "date": "2026-01-02", "tags": "shared,,"}"#,
1497        )
1498        .unwrap();
1499        // c: categories as string with leading/trailing empty segments.
1500        fs::write(
1501            sidecars.join("c.meta.json"),
1502            r#"{"title": "C", "date": "2026-01-03", "tags": ["shared"], "categories": ",misc,"}"#,
1503        )
1504        .unwrap();
1505        // d: tags is neither array nor string; categories array with a
1506        //    non-string item.
1507        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        // a shares "shared" with b and c equally; the tie-break sorts
1517        // the newer date (C) first.
1518        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        // d only shares "misc" with c.
1531        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}