Skip to main content

ssg/core/
template_engine.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Template engine integration (`MiniJinja`).
5//!
6//! Wraps the [MiniJinja](https://docs.rs/minijinja) template engine to
7//! provide Jinja2-style templating with inheritance, conditionals, loops,
8//! partials, and custom filters for static site generation.
9
10#[cfg(feature = "templates")]
11use anyhow::{Context, Result};
12#[cfg(feature = "templates")]
13use std::{collections::HashMap, path::PathBuf};
14
15/// Configuration for the template engine.
16#[cfg(feature = "templates")]
17#[derive(Debug, Clone)]
18pub struct TemplateConfig {
19    /// Directory containing templates.
20    pub template_dir: PathBuf,
21    /// Global variables injected into every template context.
22    pub globals: HashMap<String, serde_json::Value>,
23    /// Whether to enable HTML auto-escaping (default: true).
24    pub autoescape: bool,
25}
26
27#[cfg(feature = "templates")]
28impl Default for TemplateConfig {
29    fn default() -> Self {
30        Self {
31            template_dir: PathBuf::from("templates/tera"),
32            globals: HashMap::new(),
33            autoescape: true,
34        }
35    }
36}
37
38/// Wraps `MiniJinja` and provides site-generation-specific rendering.
39#[cfg(feature = "templates")]
40#[derive(Debug)]
41pub struct TemplateEngine {
42    env: minijinja::Environment<'static>,
43    config: TemplateConfig,
44}
45
46#[cfg(feature = "templates")]
47impl TemplateEngine {
48    /// Initializes the template engine from a template directory.
49    ///
50    /// Uses a path-based loader for lazy template resolution.
51    /// Returns `Ok(None)` if the template directory does not exist
52    /// (graceful fallback for projects without templates).
53    ///
54    /// # Examples
55    ///
56    /// ```rust
57    /// use ssg::template_engine::{TemplateConfig, TemplateEngine};
58    /// use tempfile::tempdir;
59    ///
60    /// let dir = tempdir().unwrap();
61    /// let cfg = TemplateConfig {
62    ///     template_dir: dir.path().join("missing"),
63    ///     ..TemplateConfig::default()
64    /// };
65    /// // Missing dir ⇒ Ok(None), never an error.
66    /// assert!(TemplateEngine::init(cfg).unwrap().is_none());
67    /// ```
68    pub fn init(config: TemplateConfig) -> Result<Option<Self>> {
69        if !config.template_dir.exists() {
70            return Ok(None);
71        }
72
73        let mut env = minijinja::Environment::new();
74        env.set_loader(minijinja::path_loader(&config.template_dir));
75
76        if !config.autoescape {
77            env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
78        }
79
80        // Register custom filters
81        env.add_filter("reading_time", reading_time_filter);
82        env.add_filter("slugify", slugify_filter);
83
84        Ok(Some(Self { env, config }))
85    }
86
87    /// Renders a page through the template chain.
88    ///
89    /// # Arguments
90    /// * `template_name` — template to render (e.g. `"page.html"`)
91    /// * `page_content` — compiled HTML content from staticdatagen
92    /// * `frontmatter` — parsed frontmatter as JSON key-value pairs
93    /// * `site_globals` — site-level variables (name, `base_url`, etc.)
94    ///
95    /// # Per-page language (spec A5, plan §2 1.5)
96    ///
97    /// Inside the render context, `site.language` and `page.language`
98    /// both carry the page's *resolved* language rather than the raw
99    /// site-wide default: front-matter `language` wins, then
100    /// front-matter `hreflang`, then the `site.language` global, then
101    /// `"en"` — normalised to BCP-47 hyphen form. Because the default
102    /// templates emit `<html lang="{{ site.language }}">`, a `/hi/…`
103    /// page with front-matter `language: hi` renders
104    /// `<html lang="hi">` even when the site default is `en-GB`, so
105    /// `<html lang>` agrees with the JSON-LD `inLanguage`,
106    /// `og:locale`, and hreflang self-reference sinks that resolve
107    /// through `seo::lang::resolve_page_lang`.
108    ///
109    /// # Examples
110    ///
111    /// ```rust
112    /// use ssg::template_engine::{TemplateConfig, TemplateEngine};
113    /// use tempfile::tempdir;
114    /// use std::collections::HashMap;
115    /// use std::fs;
116    ///
117    /// let dir = tempdir().unwrap();
118    /// let cfg = TemplateConfig {
119    ///     template_dir: dir.path().to_path_buf(),
120    ///     ..TemplateConfig::default()
121    /// };
122    /// // No matching template ⇒ returns the content unchanged.
123    /// let engine = TemplateEngine::init(cfg).unwrap().unwrap();
124    /// let html = engine.render_page("p.html", "<p>hi</p>", &HashMap::new(), &HashMap::new()).unwrap();
125    /// assert!(html.contains("hi"));
126    /// ```
127    pub fn render_page(
128        &self,
129        template_name: &str,
130        page_content: &str,
131        frontmatter: &HashMap<String, serde_json::Value>,
132        site_globals: &HashMap<String, serde_json::Value>,
133    ) -> Result<String> {
134        // Resolve the page language once so every `<html lang>`
135        // emitter publishes the per-page value (spec A5, plan §2 1.5):
136        // front-matter `language` → front-matter `hreflang` → site
137        // default → "en", normalised to BCP-47 hyphen form. This is
138        // the emitter-side subset of `seo::lang::resolve_page_lang`.
139        let resolved_lang = crate::core_group::lang::resolve_render_lang(
140            frontmatter,
141            site_globals
142                .get("language")
143                .and_then(serde_json::Value::as_str),
144        );
145
146        // Build page context
147        let mut page: serde_json::Map<String, serde_json::Value> = frontmatter
148            .iter()
149            .map(|(k, v)| (k.clone(), v.clone()))
150            .collect();
151        let _ = page.insert(
152            "content".to_string(),
153            serde_json::Value::String(page_content.to_string()),
154        );
155        let _ = page.insert(
156            "language".to_string(),
157            serde_json::Value::String(resolved_lang.clone()),
158        );
159        // Writing direction for the resolved language. Without it a
160        // template has no way to emit `dir` short of hardcoding a
161        // language list, and an Arabic or Hebrew page renders
162        // left-to-right.
163        let direction = crate::core_group::lang::text_direction(&resolved_lang);
164        let _ = page.insert(
165            "direction".to_string(),
166            serde_json::Value::String(direction.to_string()),
167        );
168
169        // Build the full render context. `site.language` carries the
170        // per-page resolved language so templates that emit
171        // `<html lang="{{ site.language }}">` (scaffold base.html and
172        // user templates alike) publish the page's language without
173        // needing template changes.
174        let mut site: serde_json::Map<String, serde_json::Value> = site_globals
175            .iter()
176            .map(|(k, v)| (k.clone(), v.clone()))
177            .collect();
178        let _ = site.insert(
179            "language".to_string(),
180            serde_json::Value::String(resolved_lang),
181        );
182        let _ = site.insert(
183            "direction".to_string(),
184            serde_json::Value::String(direction.to_string()),
185        );
186
187        let mut ctx = serde_json::Map::new();
188        let _ = ctx.insert("page".to_string(), serde_json::Value::Object(page));
189        let _ = ctx.insert("site".to_string(), serde_json::Value::Object(site));
190
191        // Inject global config variables at top level
192        for (k, v) in &self.config.globals {
193            let _ = ctx.insert(k.clone(), v.clone());
194        }
195
196        // The bundled templates address `{{language}}` and
197        // `{{direction}}` bare rather than through `site.`, so both have
198        // to exist at top level. Inserted *after* the globals loop: a
199        // stale `direction` global must not shadow the value resolved
200        // for this page.
201        let _ = ctx.insert(
202            "direction".to_string(),
203            serde_json::Value::String(direction.to_string()),
204        );
205
206        // Determine which template to use, fall back to page.html.
207        // Single lookup per candidate — the successful `get_template`
208        // result is reused directly, so the resolve and load steps can
209        // never disagree.
210        let (tmpl_name, tmpl) =
211            if let Ok(t) = self.env.get_template(template_name) {
212                (template_name, t)
213            } else if let Ok(t) = self.env.get_template("page.html") {
214                ("page.html", t)
215            } else {
216                // No matching template — return content as-is
217                return Ok(page_content.to_string());
218            };
219
220        tmpl.render(serde_json::Value::Object(ctx))
221            .with_context(|| format!("Failed to render template '{tmpl_name}'"))
222    }
223
224    /// Reports whether `name` resolves to a loadable template.
225    ///
226    /// Callers use this to distinguish "rendered through a template"
227    /// from [`render_page`](Self::render_page)'s pass-through arm,
228    /// which hands the input back unchanged when neither the requested
229    /// template nor the `page.html` fallback exists.
230    ///
231    /// # Examples
232    ///
233    /// ```rust
234    /// use ssg::template_engine::{TemplateConfig, TemplateEngine};
235    /// use tempfile::tempdir;
236    /// use std::fs;
237    ///
238    /// let dir = tempdir().unwrap();
239    /// fs::write(dir.path().join("page.html"), "{{ page.content }}").unwrap();
240    /// let cfg = TemplateConfig {
241    ///     template_dir: dir.path().to_path_buf(),
242    ///     ..TemplateConfig::default()
243    /// };
244    /// let engine = TemplateEngine::init(cfg).unwrap().unwrap();
245    /// assert!(engine.has_template("page.html"));
246    /// assert!(!engine.has_template("missing.html"));
247    /// ```
248    #[must_use]
249    pub fn has_template(&self, name: &str) -> bool {
250        self.env.get_template(name).is_ok()
251    }
252
253    /// Builds site-level globals from an `SsgConfig`.
254    ///
255    /// # Examples
256    ///
257    /// ```rust
258    /// use ssg::cmd::SsgConfig;
259    /// use ssg::template_engine::TemplateEngine;
260    ///
261    /// let cfg = SsgConfig::default();
262    /// let globals = TemplateEngine::site_globals_from_config(&cfg);
263    /// assert!(globals.contains_key("name"));
264    /// assert!(globals.contains_key("base_url"));
265    /// ```
266    #[must_use]
267    pub fn site_globals_from_config(
268        config: &crate::cmd::SsgConfig,
269    ) -> HashMap<String, serde_json::Value> {
270        // A value the config does not set is left out rather than
271        // published as "", so a template's `default(value=...)` applies:
272        // MiniJinja's `default` replaces an undefined value, not an empty
273        // one, and the scaffold's index title block relies on it.
274        let mut globals = HashMap::new();
275        for (key, value) in [
276            ("name", &config.site_name),
277            ("title", &config.site_title),
278            ("description", &config.site_description),
279            ("base_url", &config.base_url),
280            ("language", &config.language),
281        ] {
282            if !value.is_empty() {
283                let _ = globals.insert(
284                    key.to_string(),
285                    serde_json::Value::String(value.clone()),
286                );
287            }
288        }
289        globals
290    }
291
292    /// Loads data files from a `data/` directory into the context.
293    ///
294    /// Supports `.toml`, `.json`, and `.yml`/`.yaml` files.
295    /// Files are accessible as `{{ data.filename }}` in templates.
296    ///
297    /// Example: `data/nav.toml` → `{{ data.nav.links }}`
298    ///
299    /// # Examples
300    ///
301    /// ```rust
302    /// use ssg::template_engine::TemplateEngine;
303    /// use tempfile::tempdir;
304    ///
305    /// let dir = tempdir().unwrap();
306    /// // Returns empty map when no sibling `data/` dir exists.
307    /// let data = TemplateEngine::load_data_files(dir.path());
308    /// assert!(data.is_empty());
309    /// ```
310    #[must_use]
311    pub fn load_data_files(
312        content_dir: &std::path::Path,
313    ) -> HashMap<String, serde_json::Value> {
314        let data_dir = content_dir.parent().unwrap_or(content_dir).join("data");
315        let mut data = HashMap::new();
316
317        if !data_dir.exists() {
318            return data;
319        }
320
321        let Ok(entries) = std::fs::read_dir(&data_dir) else {
322            return data;
323        };
324
325        for entry in entries.flatten() {
326            let path = entry.path();
327            if !path.is_file() {
328                continue;
329            }
330
331            let stem = path
332                .file_stem()
333                .unwrap_or_default()
334                .to_string_lossy()
335                .to_string();
336            let ext = path
337                .extension()
338                .unwrap_or_default()
339                .to_string_lossy()
340                .to_lowercase();
341
342            let Ok(content) = std::fs::read_to_string(&path) else {
343                continue;
344            };
345
346            let value: Option<serde_json::Value> = match ext.as_str() {
347                "toml" => match toml::from_str::<serde_json::Value>(&content) {
348                    Ok(v) => Some(v),
349                    Err(e) => {
350                        log::warn!(
351                            "Failed to parse data file {}: {e}",
352                            path.display()
353                        );
354                        None
355                    }
356                },
357                "json" => match serde_json::from_str(&content) {
358                    Ok(v) => Some(v),
359                    Err(e) => {
360                        log::warn!(
361                            "Failed to parse data file {}: {e}",
362                            path.display()
363                        );
364                        None
365                    }
366                },
367                "yml" | "yaml" => {
368                    match noyalib::from_str::<serde_json::Value>(&content) {
369                        Ok(v) => Some(v),
370                        Err(e) => {
371                            log::warn!(
372                                "Failed to parse data file {}: {e}",
373                                path.display()
374                            );
375                            None
376                        }
377                    }
378                }
379                _ => None,
380            };
381
382            if let Some(val) = value {
383                let _ = data.insert(stem, val);
384            }
385        }
386
387        data
388    }
389}
390
391/// Custom filter: estimates reading time in minutes.
392///
393/// Usage: `{{ page.content | reading_time }}`
394/// Returns a string like "3 min read".
395#[cfg(feature = "templates")]
396fn reading_time_filter(value: String) -> String {
397    let word_count = value.split_whitespace().count();
398    let minutes = (word_count / 200).max(1);
399    format!("{minutes} min read")
400}
401
402/// Custom filter: converts a string to a URL-safe slug.
403///
404/// Usage: `{{ tag | slugify }}`
405#[cfg(feature = "templates")]
406fn slugify_filter(value: String) -> String {
407    value
408        .to_lowercase()
409        .chars()
410        .map(|c| if c.is_alphanumeric() { c } else { '-' })
411        .collect::<String>()
412        .split('-')
413        .filter(|s| !s.is_empty())
414        .collect::<Vec<_>>()
415        .join("-")
416}
417
418#[cfg(all(test, feature = "templates"))]
419mod tests {
420    use super::*;
421    use std::fs;
422    use std::path::Path;
423    use tempfile::tempdir;
424
425    fn setup_templates(dir: &Path) {
426        crate::test_support::init_logger();
427        let tera_dir = dir.join("tera");
428        fs::create_dir_all(&tera_dir).unwrap();
429
430        fs::write(
431            tera_dir.join("base.html"),
432            r#"<!DOCTYPE html>
433<html lang="{{ site.language | default("en") }}">
434<head><title>{% block title %}{{ page.title | default("Untitled") }}{% endblock %}</title>
435{% block head_extra %}{% endblock %}
436</head>
437<body>
438<main>{% block content %}{% endblock %}</main>
439<footer>{% block footer %}<p>&copy; {{ site.name | default("") }}</p>{% endblock %}</footer>
440</body>
441</html>"#,
442        )
443        .unwrap();
444
445        fs::write(
446            tera_dir.join("page.html"),
447            r#"{% extends "base.html" %}
448{% block content %}{{ page.content | safe }}{% endblock %}"#,
449        )
450        .unwrap();
451
452        fs::write(
453            tera_dir.join("post.html"),
454            r#"{% extends "base.html" %}
455{% block content %}
456<article>
457<h1>{{ page.title | default("") }}</h1>
458<time>{{ page.date | default("") }}</time>
459<p>{{ page.content | reading_time }}</p>
460{{ page.content | safe }}
461</article>
462{% endblock %}"#,
463        )
464        .unwrap();
465    }
466
467    #[test]
468    fn test_init_missing_dir() {
469        let config = TemplateConfig {
470            template_dir: PathBuf::from("/nonexistent/path"),
471            ..Default::default()
472        };
473        let result = TemplateEngine::init(config).unwrap();
474        assert!(result.is_none());
475    }
476
477    #[test]
478    fn test_init_and_render_page() {
479        let dir = tempdir().unwrap();
480        setup_templates(dir.path());
481
482        let config = TemplateConfig {
483            template_dir: dir.path().join("tera"),
484            ..Default::default()
485        };
486        let engine = TemplateEngine::init(config).unwrap().unwrap();
487
488        let mut fm = HashMap::new();
489        let _ = fm.insert(
490            "title".to_string(),
491            serde_json::Value::String("Hello".to_string()),
492        );
493
494        let mut site = HashMap::new();
495        let _ = site.insert(
496            "name".to_string(),
497            serde_json::Value::String("My Site".to_string()),
498        );
499        let _ = site.insert(
500            "language".to_string(),
501            serde_json::Value::String("en-GB".to_string()),
502        );
503
504        let result = engine
505            .render_page("page.html", "<p>Body</p>", &fm, &site)
506            .unwrap();
507
508        assert!(result.contains("Hello"));
509        assert!(result.contains("<p>Body</p>"));
510        assert!(result.contains("My Site"));
511        assert!(result.contains("en-GB"));
512    }
513
514    // ── Per-page <html lang> (spec A5, plan §2 1.5) ────────────────
515
516    fn engine_with_default_templates(dir: &Path) -> TemplateEngine {
517        setup_templates(dir);
518        let config = TemplateConfig {
519            template_dir: dir.join("tera"),
520            ..Default::default()
521        };
522        TemplateEngine::init(config).unwrap().unwrap()
523    }
524
525    fn site_with_language(lang: &str) -> HashMap<String, serde_json::Value> {
526        let mut site = HashMap::new();
527        let _ = site.insert(
528            "language".to_string(),
529            serde_json::Value::String(lang.to_string()),
530        );
531        site
532    }
533
534    #[test]
535    fn frontmatter_language_wins_over_site_language_in_html_lang() {
536        // The A5 acceptance shape: a page with front-matter
537        // `language: hi` must render `<html lang="hi">` even when the
538        // site-wide default is en-GB.
539        let dir = tempdir().unwrap();
540        let engine = engine_with_default_templates(dir.path());
541
542        let mut fm = HashMap::new();
543        let _ = fm.insert(
544            "language".to_string(),
545            serde_json::Value::String("hi".to_string()),
546        );
547
548        let result = engine
549            .render_page(
550                "page.html",
551                "<p>B</p>",
552                &fm,
553                &site_with_language("en-GB"),
554            )
555            .unwrap();
556        assert!(
557            result.contains(r#"<html lang="hi">"#),
558            "front-matter language must win: {result}"
559        );
560        assert!(!result.contains(r#"lang="en-GB""#));
561    }
562
563    #[test]
564    fn frontmatter_hreflang_used_when_language_absent() {
565        let dir = tempdir().unwrap();
566        let engine = engine_with_default_templates(dir.path());
567
568        let mut fm = HashMap::new();
569        let _ = fm.insert(
570            "hreflang".to_string(),
571            serde_json::Value::String("fr_fr".to_string()),
572        );
573
574        let result = engine
575            .render_page(
576                "page.html",
577                "<p>B</p>",
578                &fm,
579                &site_with_language("en"),
580            )
581            .unwrap();
582        // Also normalised to BCP-47 hyphen form.
583        assert!(
584            result.contains(r#"<html lang="fr-FR">"#),
585            "hreflang should be used and normalised: {result}"
586        );
587    }
588
589    #[test]
590    fn site_language_used_when_page_has_no_lang_signal() {
591        let dir = tempdir().unwrap();
592        let engine = engine_with_default_templates(dir.path());
593
594        let result = engine
595            .render_page(
596                "page.html",
597                "<p>B</p>",
598                &HashMap::new(),
599                &site_with_language("en-GB"),
600            )
601            .unwrap();
602        assert!(result.contains(r#"<html lang="en-GB">"#));
603    }
604
605    #[test]
606    fn en_fallback_when_no_language_anywhere() {
607        let dir = tempdir().unwrap();
608        let engine = engine_with_default_templates(dir.path());
609
610        let result = engine
611            .render_page(
612                "page.html",
613                "<p>B</p>",
614                &HashMap::new(),
615                &HashMap::new(),
616            )
617            .unwrap();
618        assert!(result.contains(r#"<html lang="en">"#));
619    }
620
621    #[test]
622    fn test_render_post_with_reading_time() {
623        let dir = tempdir().unwrap();
624        setup_templates(dir.path());
625
626        let config = TemplateConfig {
627            template_dir: dir.path().join("tera"),
628            ..Default::default()
629        };
630        let engine = TemplateEngine::init(config).unwrap().unwrap();
631
632        let content = "word ".repeat(600); // ~3 min read
633        let mut fm = HashMap::new();
634        let _ = fm.insert(
635            "title".to_string(),
636            serde_json::Value::String("Post".to_string()),
637        );
638        let _ = fm.insert(
639            "date".to_string(),
640            serde_json::Value::String("2026-01-01".to_string()),
641        );
642
643        let site = HashMap::new();
644        let result = engine
645            .render_page("post.html", &content, &fm, &site)
646            .unwrap();
647
648        assert!(result.contains("3 min read"));
649        assert!(result.contains("<article>"));
650    }
651
652    #[test]
653    fn test_fallback_to_page_html() {
654        let dir = tempdir().unwrap();
655        setup_templates(dir.path());
656
657        let config = TemplateConfig {
658            template_dir: dir.path().join("tera"),
659            ..Default::default()
660        };
661        let engine = TemplateEngine::init(config).unwrap().unwrap();
662
663        let fm = HashMap::new();
664        let site = HashMap::new();
665        let result = engine
666            .render_page("nonexistent.html", "<p>fallback</p>", &fm, &site)
667            .unwrap();
668
669        assert!(result.contains("<p>fallback</p>"));
670    }
671
672    #[test]
673    fn test_reading_time_filter_direct() {
674        let text = "word ".repeat(400);
675        let result = reading_time_filter(text);
676        assert_eq!(result, "2 min read");
677    }
678
679    #[test]
680    fn test_slugify_filter() {
681        assert_eq!(slugify_filter("Hello World!".to_string()), "hello-world");
682        assert_eq!(slugify_filter("Rust & Web".to_string()), "rust-web");
683    }
684
685    // -------------------------------------------------------------------
686    // load_data_files — format + fallback coverage
687    // -------------------------------------------------------------------
688
689    #[test]
690    fn load_data_files_missing_data_dir_returns_empty_map() {
691        let dir = tempdir().unwrap();
692        let content = dir.path().join("content");
693        fs::create_dir_all(&content).unwrap();
694        let result = TemplateEngine::load_data_files(&content);
695        assert!(result.is_empty());
696    }
697
698    #[test]
699    fn load_data_files_parses_toml_and_json_and_yaml() {
700        let dir = tempdir().unwrap();
701        let content = dir.path().join("content");
702        fs::create_dir_all(&content).unwrap();
703        let data = dir.path().join("data");
704        fs::create_dir_all(&data).unwrap();
705
706        fs::write(data.join("site.toml"), r#"key = "toml-value""#).unwrap();
707        fs::write(data.join("nav.json"), r#"{"items": ["home", "about"]}"#)
708            .unwrap();
709        fs::write(data.join("conf.yml"), r#"{"yaml": "value"}"#).unwrap();
710        fs::write(data.join("ignored.txt"), "not parsed").unwrap();
711
712        let sub = data.join("sub");
713        fs::create_dir_all(&sub).unwrap();
714        fs::write(sub.join("inside.json"), "{}").unwrap();
715
716        let result = TemplateEngine::load_data_files(&content);
717        assert!(result.contains_key("site"));
718        assert!(result.contains_key("nav"));
719        assert!(result.contains_key("conf"));
720        assert!(!result.contains_key("ignored"));
721        assert!(!result.contains_key("sub"));
722    }
723
724    #[test]
725    fn load_data_files_skips_files_with_invalid_content() {
726        let dir = tempdir().unwrap();
727        let content = dir.path().join("content");
728        fs::create_dir_all(&content).unwrap();
729        let data = dir.path().join("data");
730        fs::create_dir_all(&data).unwrap();
731
732        fs::write(data.join("broken.toml"), "not valid toml [[[").unwrap();
733        fs::write(data.join("broken.json"), "{not valid").unwrap();
734        fs::write(data.join("good.toml"), r#"x = "y""#).unwrap();
735
736        let result = TemplateEngine::load_data_files(&content);
737        assert!(result.contains_key("good"));
738        assert!(!result.contains_key("broken"));
739    }
740
741    #[test]
742    fn load_data_files_skips_non_utf8_file() {
743        // A file whose bytes are not valid UTF-8 makes
744        // `read_to_string` fail, taking the `continue` arm.
745        let dir = tempdir().unwrap();
746        let content = dir.path().join("content");
747        fs::create_dir_all(&content).unwrap();
748        let data = dir.path().join("data");
749        fs::create_dir_all(&data).unwrap();
750
751        fs::write(data.join("binary.toml"), [0xFF, 0xFE, 0x00, 0x01]).unwrap();
752        fs::write(data.join("ok.toml"), r#"k = "v""#).unwrap();
753
754        let result = TemplateEngine::load_data_files(&content);
755        assert!(result.contains_key("ok"));
756        assert!(!result.contains_key("binary"));
757    }
758
759    #[test]
760    fn load_data_files_skips_invalid_yaml() {
761        // Exercises the YAML parse-error arm (including the
762        // `log::warn!` format arguments).
763        crate::test_support::init_logger();
764        let dir = tempdir().unwrap();
765        let content = dir.path().join("content");
766        fs::create_dir_all(&content).unwrap();
767        let data = dir.path().join("data");
768        fs::create_dir_all(&data).unwrap();
769
770        fs::write(data.join("broken.yml"), "key: [unclosed").unwrap();
771        fs::write(data.join("good.yaml"), "k: v").unwrap();
772
773        let result = TemplateEngine::load_data_files(&content);
774        assert!(result.contains_key("good"));
775        assert!(!result.contains_key("broken"));
776    }
777
778    /// RUSTSEC-2026-0333: a `data/*.yml` file goes through noyalib's
779    /// typed path too. Before noyalib 0.0.53 a 576-byte alias fan-out
780    /// expanded to 3.4 MB of JSON there; now it is refused and skipped
781    /// like any unparseable data file, and the other files still load.
782    #[test]
783    fn load_data_files_refuses_yaml_alias_amplification() {
784        crate::test_support::init_logger();
785        let dir = tempdir().unwrap();
786        let content = dir.path().join("content");
787        fs::create_dir_all(&content).unwrap();
788        let data = dir.path().join("data");
789        fs::create_dir_all(&data).unwrap();
790
791        let mut bomb = String::new();
792        for level in 0..4 {
793            let item = if level == 0 {
794                "x".to_string()
795            } else {
796                format!("*l{}", level - 1)
797            };
798            let items = vec![item; 30].join(", ");
799            bomb.push_str(&format!("l{level}: &l{level} [{items}]\n"));
800        }
801        fs::write(data.join("bomb.yml"), &bomb).unwrap();
802        fs::write(data.join("ok.yaml"), "base: &b [a, b]\ncopy: *b").unwrap();
803
804        let result = TemplateEngine::load_data_files(&content);
805        assert!(
806            !result.contains_key("bomb"),
807            "{}-byte data file was accepted",
808            bomb.len()
809        );
810        assert_eq!(result["ok"]["copy"], result["ok"]["base"]);
811    }
812
813    #[test]
814    fn load_data_files_ignores_unsupported_extensions() {
815        let dir = tempdir().unwrap();
816        let content = dir.path().join("content");
817        fs::create_dir_all(&content).unwrap();
818        let data = dir.path().join("data");
819        fs::create_dir_all(&data).unwrap();
820
821        fs::write(data.join("a.xml"), "<x/>").unwrap();
822        fs::write(data.join("b.csv"), "a,b").unwrap();
823        fs::write(data.join("c"), "no extension").unwrap();
824
825        let result = TemplateEngine::load_data_files(&content);
826        assert!(result.is_empty());
827    }
828
829    // -------------------------------------------------------------------
830    // render_page — custom globals + no-fallback branch
831    // -------------------------------------------------------------------
832
833    #[test]
834    fn render_page_injects_custom_globals_from_config() {
835        let dir = tempdir().unwrap();
836        setup_templates(dir.path());
837
838        // Write a minimal template that references the custom global.
839        fs::write(
840            dir.path().join("tera").join("branded.html"),
841            r"<p>{{ brand }}</p>",
842        )
843        .unwrap();
844
845        let config = TemplateConfig {
846            template_dir: dir.path().join("tera"),
847            globals: {
848                let mut g = HashMap::new();
849                let _ = g.insert(
850                    "brand".to_string(),
851                    serde_json::Value::String("Acme".to_string()),
852                );
853                g
854            },
855            ..Default::default()
856        };
857        let engine = TemplateEngine::init(config).unwrap().unwrap();
858
859        let result = engine
860            .render_page("branded.html", "", &HashMap::new(), &HashMap::new())
861            .unwrap();
862        assert!(result.contains("Acme"));
863    }
864
865    #[test]
866    fn render_page_no_matching_template_and_no_page_html_returns_content_as_is()
867    {
868        let dir = tempdir().unwrap();
869        let tera_dir = dir.path().join("tera");
870        fs::create_dir_all(&tera_dir).unwrap();
871        // Only write a `base.html`, NOT a `page.html`.
872        fs::write(
873            tera_dir.join("base.html"),
874            r"<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>",
875        )
876        .unwrap();
877
878        let config = TemplateConfig {
879            template_dir: tera_dir,
880            ..Default::default()
881        };
882        let engine = TemplateEngine::init(config).unwrap().unwrap();
883
884        let content = "<p>raw content</p>";
885        let result = engine
886            .render_page(
887                "nonexistent.html",
888                content,
889                &HashMap::new(),
890                &HashMap::new(),
891            )
892            .unwrap();
893        assert_eq!(result, content);
894    }
895
896    #[test]
897    fn init_with_autoescape_false() {
898        let dir = tempdir().unwrap();
899        setup_templates(dir.path());
900
901        let config = TemplateConfig {
902            template_dir: dir.path().join("tera"),
903            autoescape: false,
904            ..Default::default()
905        };
906        let engine = TemplateEngine::init(config).unwrap().unwrap();
907        let result = engine
908            .render_page(
909                "page.html",
910                "<p>x</p>",
911                &HashMap::new(),
912                &HashMap::new(),
913            )
914            .unwrap();
915        assert!(result.contains("<p>x</p>"));
916    }
917
918    #[test]
919    fn init_with_broken_template_errors_on_render() {
920        let dir = tempdir().unwrap();
921        let tera_dir = dir.path().join("tera");
922        fs::create_dir_all(&tera_dir).unwrap();
923        // Use an extends to a non-existent parent — always errors on render
924        fs::write(tera_dir.join("broken.html"), "{% extends \"nonexistent_parent.html\" %}{% block x %}{% endblock %}").unwrap();
925
926        let config = TemplateConfig {
927            template_dir: tera_dir,
928            ..Default::default()
929        };
930        // MiniJinja uses lazy loading — init succeeds
931        let engine = TemplateEngine::init(config).unwrap().unwrap();
932        // Error surfaces at render time
933        let result = engine.render_page(
934            "broken.html",
935            "",
936            &HashMap::new(),
937            &HashMap::new(),
938        );
939        assert!(result.is_err());
940    }
941
942    #[test]
943    #[cfg(unix)]
944    fn load_data_files_unreadable_file_continues_silently() {
945        let dir = tempdir().unwrap();
946        let content = dir.path().join("content");
947        fs::create_dir_all(&content).unwrap();
948        let data = dir.path().join("data");
949        fs::create_dir_all(&data).unwrap();
950
951        fs::create_dir_all(data.join("not-really.toml")).unwrap();
952        fs::write(data.join("real.toml"), r#"k = "v""#).unwrap();
953
954        let result = TemplateEngine::load_data_files(&content);
955        assert!(result.contains_key("real"));
956        assert!(!result.contains_key("not-really"));
957    }
958
959    #[test]
960    fn load_data_files_data_dir_is_a_file_returns_empty() {
961        let dir = tempdir().unwrap();
962        let content = dir.path().join("content");
963        fs::create_dir_all(&content).unwrap();
964        let data = dir.path().join("data");
965        fs::write(&data, "I am a file, not a directory").unwrap();
966
967        let result = TemplateEngine::load_data_files(&content);
968        assert!(result.is_empty());
969    }
970
971    #[test]
972    fn render_page_propagates_render_errors() {
973        let dir = tempdir().unwrap();
974        let tera_dir = dir.path().join("tera");
975        fs::create_dir_all(&tera_dir).unwrap();
976        // Undefined filter → render fails
977        fs::write(
978            tera_dir.join("broken.html"),
979            r"{{ page.title | nonexistent_filter }}",
980        )
981        .unwrap();
982
983        let config = TemplateConfig {
984            template_dir: tera_dir,
985            ..Default::default()
986        };
987        let engine = TemplateEngine::init(config).unwrap().unwrap();
988
989        let mut fm = HashMap::new();
990        let _ = fm.insert(
991            "title".to_string(),
992            serde_json::Value::String("T".to_string()),
993        );
994
995        let result =
996            engine.render_page("broken.html", "", &fm, &HashMap::new());
997        assert!(result.is_err());
998    }
999
1000    /// An unset config value must not defeat a template's `default`.
1001    ///
1002    /// `site_globals_from_config` published every field, so a project
1003    /// with no `site_title` handed the layout `site.title == ""`, and
1004    /// `{{ site.title | default(value="Home") }}` printed nothing:
1005    /// `MiniJinja`'s `default` only replaces an undefined value. The
1006    /// scaffold's `index.html` title block does exactly that, so a fresh
1007    /// project's home page shipped `<title></title>`. Leaving empty
1008    /// values out is safe in lenient mode: an undefined value renders as
1009    /// nothing, is falsy, and passes through a filter.
1010    #[test]
1011    fn empty_config_values_are_left_out_so_template_defaults_apply() {
1012        let dir = tempdir().unwrap();
1013        let tera_dir = dir.path().join("tera");
1014        fs::create_dir_all(&tera_dir).unwrap();
1015        fs::write(
1016            tera_dir.join("t.html"),
1017            r#"[{{ site.title | default("Home") }}][{{ site.title | upper }}][{% if site.title %}set{% else %}unset{% endif %}][{{ site.name }}]"#,
1018        )
1019        .unwrap();
1020        let engine = TemplateEngine::init(TemplateConfig {
1021            template_dir: tera_dir,
1022            ..Default::default()
1023        })
1024        .unwrap()
1025        .unwrap();
1026
1027        let config = crate::cmd::SsgConfig::builder()
1028            .site_name("Demo".to_string())
1029            .site_title(String::new())
1030            .build()
1031            .unwrap();
1032        let globals = TemplateEngine::site_globals_from_config(&config);
1033        assert!(
1034            !globals.contains_key("title"),
1035            "an empty title is left out: {globals:?}"
1036        );
1037        assert_eq!(
1038            globals.get("name").and_then(serde_json::Value::as_str),
1039            Some("Demo")
1040        );
1041
1042        let out = engine
1043            .render_page("t.html", "", &HashMap::new(), &globals)
1044            .unwrap();
1045        assert_eq!(out, "[Home][][unset][Demo]");
1046    }
1047}