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        let mut globals = HashMap::new();
271        let _ = globals.insert(
272            "name".to_string(),
273            serde_json::Value::String(config.site_name.clone()),
274        );
275        let _ = globals.insert(
276            "title".to_string(),
277            serde_json::Value::String(config.site_title.clone()),
278        );
279        let _ = globals.insert(
280            "description".to_string(),
281            serde_json::Value::String(config.site_description.clone()),
282        );
283        let _ = globals.insert(
284            "base_url".to_string(),
285            serde_json::Value::String(config.base_url.clone()),
286        );
287        let _ = globals.insert(
288            "language".to_string(),
289            serde_json::Value::String(config.language.clone()),
290        );
291        globals
292    }
293
294    /// Loads data files from a `data/` directory into the context.
295    ///
296    /// Supports `.toml`, `.json`, and `.yml`/`.yaml` files.
297    /// Files are accessible as `{{ data.filename }}` in templates.
298    ///
299    /// Example: `data/nav.toml` → `{{ data.nav.links }}`
300    ///
301    /// # Examples
302    ///
303    /// ```rust
304    /// use ssg::template_engine::TemplateEngine;
305    /// use tempfile::tempdir;
306    ///
307    /// let dir = tempdir().unwrap();
308    /// // Returns empty map when no sibling `data/` dir exists.
309    /// let data = TemplateEngine::load_data_files(dir.path());
310    /// assert!(data.is_empty());
311    /// ```
312    #[must_use]
313    pub fn load_data_files(
314        content_dir: &std::path::Path,
315    ) -> HashMap<String, serde_json::Value> {
316        let data_dir = content_dir.parent().unwrap_or(content_dir).join("data");
317        let mut data = HashMap::new();
318
319        if !data_dir.exists() {
320            return data;
321        }
322
323        let Ok(entries) = std::fs::read_dir(&data_dir) else {
324            return data;
325        };
326
327        for entry in entries.flatten() {
328            let path = entry.path();
329            if !path.is_file() {
330                continue;
331            }
332
333            let stem = path
334                .file_stem()
335                .unwrap_or_default()
336                .to_string_lossy()
337                .to_string();
338            let ext = path
339                .extension()
340                .unwrap_or_default()
341                .to_string_lossy()
342                .to_lowercase();
343
344            let Ok(content) = std::fs::read_to_string(&path) else {
345                continue;
346            };
347
348            let value: Option<serde_json::Value> = match ext.as_str() {
349                "toml" => match toml::from_str::<serde_json::Value>(&content) {
350                    Ok(v) => Some(v),
351                    Err(e) => {
352                        log::warn!(
353                            "Failed to parse data file {}: {e}",
354                            path.display()
355                        );
356                        None
357                    }
358                },
359                "json" => match serde_json::from_str(&content) {
360                    Ok(v) => Some(v),
361                    Err(e) => {
362                        log::warn!(
363                            "Failed to parse data file {}: {e}",
364                            path.display()
365                        );
366                        None
367                    }
368                },
369                "yml" | "yaml" => {
370                    match noyalib::from_str::<serde_json::Value>(&content) {
371                        Ok(v) => Some(v),
372                        Err(e) => {
373                            log::warn!(
374                                "Failed to parse data file {}: {e}",
375                                path.display()
376                            );
377                            None
378                        }
379                    }
380                }
381                _ => None,
382            };
383
384            if let Some(val) = value {
385                let _ = data.insert(stem, val);
386            }
387        }
388
389        data
390    }
391}
392
393/// Custom filter: estimates reading time in minutes.
394///
395/// Usage: `{{ page.content | reading_time }}`
396/// Returns a string like "3 min read".
397#[cfg(feature = "templates")]
398fn reading_time_filter(value: String) -> String {
399    let word_count = value.split_whitespace().count();
400    let minutes = (word_count / 200).max(1);
401    format!("{minutes} min read")
402}
403
404/// Custom filter: converts a string to a URL-safe slug.
405///
406/// Usage: `{{ tag | slugify }}`
407#[cfg(feature = "templates")]
408fn slugify_filter(value: String) -> String {
409    value
410        .to_lowercase()
411        .chars()
412        .map(|c| if c.is_alphanumeric() { c } else { '-' })
413        .collect::<String>()
414        .split('-')
415        .filter(|s| !s.is_empty())
416        .collect::<Vec<_>>()
417        .join("-")
418}
419
420#[cfg(all(test, feature = "templates"))]
421mod tests {
422    use super::*;
423    use std::fs;
424    use std::path::Path;
425    use tempfile::tempdir;
426
427    fn setup_templates(dir: &Path) {
428        crate::test_support::init_logger();
429        let tera_dir = dir.join("tera");
430        fs::create_dir_all(&tera_dir).unwrap();
431
432        fs::write(
433            tera_dir.join("base.html"),
434            r#"<!DOCTYPE html>
435<html lang="{{ site.language | default("en") }}">
436<head><title>{% block title %}{{ page.title | default("Untitled") }}{% endblock %}</title>
437{% block head_extra %}{% endblock %}
438</head>
439<body>
440<main>{% block content %}{% endblock %}</main>
441<footer>{% block footer %}<p>&copy; {{ site.name | default("") }}</p>{% endblock %}</footer>
442</body>
443</html>"#,
444        )
445        .unwrap();
446
447        fs::write(
448            tera_dir.join("page.html"),
449            r#"{% extends "base.html" %}
450{% block content %}{{ page.content | safe }}{% endblock %}"#,
451        )
452        .unwrap();
453
454        fs::write(
455            tera_dir.join("post.html"),
456            r#"{% extends "base.html" %}
457{% block content %}
458<article>
459<h1>{{ page.title | default("") }}</h1>
460<time>{{ page.date | default("") }}</time>
461<p>{{ page.content | reading_time }}</p>
462{{ page.content | safe }}
463</article>
464{% endblock %}"#,
465        )
466        .unwrap();
467    }
468
469    #[test]
470    fn test_init_missing_dir() {
471        let config = TemplateConfig {
472            template_dir: PathBuf::from("/nonexistent/path"),
473            ..Default::default()
474        };
475        let result = TemplateEngine::init(config).unwrap();
476        assert!(result.is_none());
477    }
478
479    #[test]
480    fn test_init_and_render_page() {
481        let dir = tempdir().unwrap();
482        setup_templates(dir.path());
483
484        let config = TemplateConfig {
485            template_dir: dir.path().join("tera"),
486            ..Default::default()
487        };
488        let engine = TemplateEngine::init(config).unwrap().unwrap();
489
490        let mut fm = HashMap::new();
491        let _ = fm.insert(
492            "title".to_string(),
493            serde_json::Value::String("Hello".to_string()),
494        );
495
496        let mut site = HashMap::new();
497        let _ = site.insert(
498            "name".to_string(),
499            serde_json::Value::String("My Site".to_string()),
500        );
501        let _ = site.insert(
502            "language".to_string(),
503            serde_json::Value::String("en-GB".to_string()),
504        );
505
506        let result = engine
507            .render_page("page.html", "<p>Body</p>", &fm, &site)
508            .unwrap();
509
510        assert!(result.contains("Hello"));
511        assert!(result.contains("<p>Body</p>"));
512        assert!(result.contains("My Site"));
513        assert!(result.contains("en-GB"));
514    }
515
516    // ── Per-page <html lang> (spec A5, plan §2 1.5) ────────────────
517
518    fn engine_with_default_templates(dir: &Path) -> TemplateEngine {
519        setup_templates(dir);
520        let config = TemplateConfig {
521            template_dir: dir.join("tera"),
522            ..Default::default()
523        };
524        TemplateEngine::init(config).unwrap().unwrap()
525    }
526
527    fn site_with_language(lang: &str) -> HashMap<String, serde_json::Value> {
528        let mut site = HashMap::new();
529        let _ = site.insert(
530            "language".to_string(),
531            serde_json::Value::String(lang.to_string()),
532        );
533        site
534    }
535
536    #[test]
537    fn frontmatter_language_wins_over_site_language_in_html_lang() {
538        // The A5 acceptance shape: a page with front-matter
539        // `language: hi` must render `<html lang="hi">` even when the
540        // site-wide default is en-GB.
541        let dir = tempdir().unwrap();
542        let engine = engine_with_default_templates(dir.path());
543
544        let mut fm = HashMap::new();
545        let _ = fm.insert(
546            "language".to_string(),
547            serde_json::Value::String("hi".to_string()),
548        );
549
550        let result = engine
551            .render_page(
552                "page.html",
553                "<p>B</p>",
554                &fm,
555                &site_with_language("en-GB"),
556            )
557            .unwrap();
558        assert!(
559            result.contains(r#"<html lang="hi">"#),
560            "front-matter language must win: {result}"
561        );
562        assert!(!result.contains(r#"lang="en-GB""#));
563    }
564
565    #[test]
566    fn frontmatter_hreflang_used_when_language_absent() {
567        let dir = tempdir().unwrap();
568        let engine = engine_with_default_templates(dir.path());
569
570        let mut fm = HashMap::new();
571        let _ = fm.insert(
572            "hreflang".to_string(),
573            serde_json::Value::String("fr_fr".to_string()),
574        );
575
576        let result = engine
577            .render_page(
578                "page.html",
579                "<p>B</p>",
580                &fm,
581                &site_with_language("en"),
582            )
583            .unwrap();
584        // Also normalised to BCP-47 hyphen form.
585        assert!(
586            result.contains(r#"<html lang="fr-FR">"#),
587            "hreflang should be used and normalised: {result}"
588        );
589    }
590
591    #[test]
592    fn site_language_used_when_page_has_no_lang_signal() {
593        let dir = tempdir().unwrap();
594        let engine = engine_with_default_templates(dir.path());
595
596        let result = engine
597            .render_page(
598                "page.html",
599                "<p>B</p>",
600                &HashMap::new(),
601                &site_with_language("en-GB"),
602            )
603            .unwrap();
604        assert!(result.contains(r#"<html lang="en-GB">"#));
605    }
606
607    #[test]
608    fn en_fallback_when_no_language_anywhere() {
609        let dir = tempdir().unwrap();
610        let engine = engine_with_default_templates(dir.path());
611
612        let result = engine
613            .render_page(
614                "page.html",
615                "<p>B</p>",
616                &HashMap::new(),
617                &HashMap::new(),
618            )
619            .unwrap();
620        assert!(result.contains(r#"<html lang="en">"#));
621    }
622
623    #[test]
624    fn test_render_post_with_reading_time() {
625        let dir = tempdir().unwrap();
626        setup_templates(dir.path());
627
628        let config = TemplateConfig {
629            template_dir: dir.path().join("tera"),
630            ..Default::default()
631        };
632        let engine = TemplateEngine::init(config).unwrap().unwrap();
633
634        let content = "word ".repeat(600); // ~3 min read
635        let mut fm = HashMap::new();
636        let _ = fm.insert(
637            "title".to_string(),
638            serde_json::Value::String("Post".to_string()),
639        );
640        let _ = fm.insert(
641            "date".to_string(),
642            serde_json::Value::String("2026-01-01".to_string()),
643        );
644
645        let site = HashMap::new();
646        let result = engine
647            .render_page("post.html", &content, &fm, &site)
648            .unwrap();
649
650        assert!(result.contains("3 min read"));
651        assert!(result.contains("<article>"));
652    }
653
654    #[test]
655    fn test_fallback_to_page_html() {
656        let dir = tempdir().unwrap();
657        setup_templates(dir.path());
658
659        let config = TemplateConfig {
660            template_dir: dir.path().join("tera"),
661            ..Default::default()
662        };
663        let engine = TemplateEngine::init(config).unwrap().unwrap();
664
665        let fm = HashMap::new();
666        let site = HashMap::new();
667        let result = engine
668            .render_page("nonexistent.html", "<p>fallback</p>", &fm, &site)
669            .unwrap();
670
671        assert!(result.contains("<p>fallback</p>"));
672    }
673
674    #[test]
675    fn test_reading_time_filter_direct() {
676        let text = "word ".repeat(400);
677        let result = reading_time_filter(text);
678        assert_eq!(result, "2 min read");
679    }
680
681    #[test]
682    fn test_slugify_filter() {
683        assert_eq!(slugify_filter("Hello World!".to_string()), "hello-world");
684        assert_eq!(slugify_filter("Rust & Web".to_string()), "rust-web");
685    }
686
687    // -------------------------------------------------------------------
688    // load_data_files — format + fallback coverage
689    // -------------------------------------------------------------------
690
691    #[test]
692    fn load_data_files_missing_data_dir_returns_empty_map() {
693        let dir = tempdir().unwrap();
694        let content = dir.path().join("content");
695        fs::create_dir_all(&content).unwrap();
696        let result = TemplateEngine::load_data_files(&content);
697        assert!(result.is_empty());
698    }
699
700    #[test]
701    fn load_data_files_parses_toml_and_json_and_yaml() {
702        let dir = tempdir().unwrap();
703        let content = dir.path().join("content");
704        fs::create_dir_all(&content).unwrap();
705        let data = dir.path().join("data");
706        fs::create_dir_all(&data).unwrap();
707
708        fs::write(data.join("site.toml"), r#"key = "toml-value""#).unwrap();
709        fs::write(data.join("nav.json"), r#"{"items": ["home", "about"]}"#)
710            .unwrap();
711        fs::write(data.join("conf.yml"), r#"{"yaml": "value"}"#).unwrap();
712        fs::write(data.join("ignored.txt"), "not parsed").unwrap();
713
714        let sub = data.join("sub");
715        fs::create_dir_all(&sub).unwrap();
716        fs::write(sub.join("inside.json"), "{}").unwrap();
717
718        let result = TemplateEngine::load_data_files(&content);
719        assert!(result.contains_key("site"));
720        assert!(result.contains_key("nav"));
721        assert!(result.contains_key("conf"));
722        assert!(!result.contains_key("ignored"));
723        assert!(!result.contains_key("sub"));
724    }
725
726    #[test]
727    fn load_data_files_skips_files_with_invalid_content() {
728        let dir = tempdir().unwrap();
729        let content = dir.path().join("content");
730        fs::create_dir_all(&content).unwrap();
731        let data = dir.path().join("data");
732        fs::create_dir_all(&data).unwrap();
733
734        fs::write(data.join("broken.toml"), "not valid toml [[[").unwrap();
735        fs::write(data.join("broken.json"), "{not valid").unwrap();
736        fs::write(data.join("good.toml"), r#"x = "y""#).unwrap();
737
738        let result = TemplateEngine::load_data_files(&content);
739        assert!(result.contains_key("good"));
740        assert!(!result.contains_key("broken"));
741    }
742
743    #[test]
744    fn load_data_files_skips_non_utf8_file() {
745        // A file whose bytes are not valid UTF-8 makes
746        // `read_to_string` fail, taking the `continue` arm.
747        let dir = tempdir().unwrap();
748        let content = dir.path().join("content");
749        fs::create_dir_all(&content).unwrap();
750        let data = dir.path().join("data");
751        fs::create_dir_all(&data).unwrap();
752
753        fs::write(data.join("binary.toml"), [0xFF, 0xFE, 0x00, 0x01]).unwrap();
754        fs::write(data.join("ok.toml"), r#"k = "v""#).unwrap();
755
756        let result = TemplateEngine::load_data_files(&content);
757        assert!(result.contains_key("ok"));
758        assert!(!result.contains_key("binary"));
759    }
760
761    #[test]
762    fn load_data_files_skips_invalid_yaml() {
763        // Exercises the YAML parse-error arm (including the
764        // `log::warn!` format arguments).
765        crate::test_support::init_logger();
766        let dir = tempdir().unwrap();
767        let content = dir.path().join("content");
768        fs::create_dir_all(&content).unwrap();
769        let data = dir.path().join("data");
770        fs::create_dir_all(&data).unwrap();
771
772        fs::write(data.join("broken.yml"), "key: [unclosed").unwrap();
773        fs::write(data.join("good.yaml"), "k: v").unwrap();
774
775        let result = TemplateEngine::load_data_files(&content);
776        assert!(result.contains_key("good"));
777        assert!(!result.contains_key("broken"));
778    }
779
780    #[test]
781    fn load_data_files_ignores_unsupported_extensions() {
782        let dir = tempdir().unwrap();
783        let content = dir.path().join("content");
784        fs::create_dir_all(&content).unwrap();
785        let data = dir.path().join("data");
786        fs::create_dir_all(&data).unwrap();
787
788        fs::write(data.join("a.xml"), "<x/>").unwrap();
789        fs::write(data.join("b.csv"), "a,b").unwrap();
790        fs::write(data.join("c"), "no extension").unwrap();
791
792        let result = TemplateEngine::load_data_files(&content);
793        assert!(result.is_empty());
794    }
795
796    // -------------------------------------------------------------------
797    // render_page — custom globals + no-fallback branch
798    // -------------------------------------------------------------------
799
800    #[test]
801    fn render_page_injects_custom_globals_from_config() {
802        let dir = tempdir().unwrap();
803        setup_templates(dir.path());
804
805        // Write a minimal template that references the custom global.
806        fs::write(
807            dir.path().join("tera").join("branded.html"),
808            r"<p>{{ brand }}</p>",
809        )
810        .unwrap();
811
812        let config = TemplateConfig {
813            template_dir: dir.path().join("tera"),
814            globals: {
815                let mut g = HashMap::new();
816                let _ = g.insert(
817                    "brand".to_string(),
818                    serde_json::Value::String("Acme".to_string()),
819                );
820                g
821            },
822            ..Default::default()
823        };
824        let engine = TemplateEngine::init(config).unwrap().unwrap();
825
826        let result = engine
827            .render_page("branded.html", "", &HashMap::new(), &HashMap::new())
828            .unwrap();
829        assert!(result.contains("Acme"));
830    }
831
832    #[test]
833    fn render_page_no_matching_template_and_no_page_html_returns_content_as_is()
834    {
835        let dir = tempdir().unwrap();
836        let tera_dir = dir.path().join("tera");
837        fs::create_dir_all(&tera_dir).unwrap();
838        // Only write a `base.html`, NOT a `page.html`.
839        fs::write(
840            tera_dir.join("base.html"),
841            r"<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>",
842        )
843        .unwrap();
844
845        let config = TemplateConfig {
846            template_dir: tera_dir,
847            ..Default::default()
848        };
849        let engine = TemplateEngine::init(config).unwrap().unwrap();
850
851        let content = "<p>raw content</p>";
852        let result = engine
853            .render_page(
854                "nonexistent.html",
855                content,
856                &HashMap::new(),
857                &HashMap::new(),
858            )
859            .unwrap();
860        assert_eq!(result, content);
861    }
862
863    #[test]
864    fn init_with_autoescape_false() {
865        let dir = tempdir().unwrap();
866        setup_templates(dir.path());
867
868        let config = TemplateConfig {
869            template_dir: dir.path().join("tera"),
870            autoescape: false,
871            ..Default::default()
872        };
873        let engine = TemplateEngine::init(config).unwrap().unwrap();
874        let result = engine
875            .render_page(
876                "page.html",
877                "<p>x</p>",
878                &HashMap::new(),
879                &HashMap::new(),
880            )
881            .unwrap();
882        assert!(result.contains("<p>x</p>"));
883    }
884
885    #[test]
886    fn init_with_broken_template_errors_on_render() {
887        let dir = tempdir().unwrap();
888        let tera_dir = dir.path().join("tera");
889        fs::create_dir_all(&tera_dir).unwrap();
890        // Use an extends to a non-existent parent — always errors on render
891        fs::write(tera_dir.join("broken.html"), "{% extends \"nonexistent_parent.html\" %}{% block x %}{% endblock %}").unwrap();
892
893        let config = TemplateConfig {
894            template_dir: tera_dir,
895            ..Default::default()
896        };
897        // MiniJinja uses lazy loading — init succeeds
898        let engine = TemplateEngine::init(config).unwrap().unwrap();
899        // Error surfaces at render time
900        let result = engine.render_page(
901            "broken.html",
902            "",
903            &HashMap::new(),
904            &HashMap::new(),
905        );
906        assert!(result.is_err());
907    }
908
909    #[test]
910    #[cfg(unix)]
911    fn load_data_files_unreadable_file_continues_silently() {
912        let dir = tempdir().unwrap();
913        let content = dir.path().join("content");
914        fs::create_dir_all(&content).unwrap();
915        let data = dir.path().join("data");
916        fs::create_dir_all(&data).unwrap();
917
918        fs::create_dir_all(data.join("not-really.toml")).unwrap();
919        fs::write(data.join("real.toml"), r#"k = "v""#).unwrap();
920
921        let result = TemplateEngine::load_data_files(&content);
922        assert!(result.contains_key("real"));
923        assert!(!result.contains_key("not-really"));
924    }
925
926    #[test]
927    fn load_data_files_data_dir_is_a_file_returns_empty() {
928        let dir = tempdir().unwrap();
929        let content = dir.path().join("content");
930        fs::create_dir_all(&content).unwrap();
931        let data = dir.path().join("data");
932        fs::write(&data, "I am a file, not a directory").unwrap();
933
934        let result = TemplateEngine::load_data_files(&content);
935        assert!(result.is_empty());
936    }
937
938    #[test]
939    fn render_page_propagates_render_errors() {
940        let dir = tempdir().unwrap();
941        let tera_dir = dir.path().join("tera");
942        fs::create_dir_all(&tera_dir).unwrap();
943        // Undefined filter → render fails
944        fs::write(
945            tera_dir.join("broken.html"),
946            r"{{ page.title | nonexistent_filter }}",
947        )
948        .unwrap();
949
950        let config = TemplateConfig {
951            template_dir: tera_dir,
952            ..Default::default()
953        };
954        let engine = TemplateEngine::init(config).unwrap().unwrap();
955
956        let mut fm = HashMap::new();
957        let _ = fm.insert(
958            "title".to_string(),
959            serde_json::Value::String("T".to_string()),
960        );
961
962        let result =
963            engine.render_page("broken.html", "", &fm, &HashMap::new());
964        assert!(result.is_err());
965    }
966}