Skip to main content

ssg/
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    pub fn init(config: TemplateConfig) -> Result<Option<Self>> {
54        if !config.template_dir.exists() {
55            return Ok(None);
56        }
57
58        let mut env = minijinja::Environment::new();
59        env.set_loader(minijinja::path_loader(&config.template_dir));
60
61        if !config.autoescape {
62            env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
63        }
64
65        // Register custom filters
66        env.add_filter("reading_time", reading_time_filter);
67        env.add_filter("slugify", slugify_filter);
68
69        Ok(Some(Self { env, config }))
70    }
71
72    /// Renders a page through the template chain.
73    ///
74    /// # Arguments
75    /// * `template_name` — template to render (e.g. `"page.html"`)
76    /// * `page_content` — compiled HTML content from staticdatagen
77    /// * `frontmatter` — parsed frontmatter as JSON key-value pairs
78    /// * `site_globals` — site-level variables (name, `base_url`, etc.)
79    pub fn render_page(
80        &self,
81        template_name: &str,
82        page_content: &str,
83        frontmatter: &HashMap<String, serde_json::Value>,
84        site_globals: &HashMap<String, serde_json::Value>,
85    ) -> Result<String> {
86        // Build page context
87        let mut page: serde_json::Map<String, serde_json::Value> = frontmatter
88            .iter()
89            .map(|(k, v)| (k.clone(), v.clone()))
90            .collect();
91        let _ = page.insert(
92            "content".to_string(),
93            serde_json::Value::String(page_content.to_string()),
94        );
95
96        // Build the full render context
97        let mut ctx = serde_json::Map::new();
98        let _ = ctx.insert("page".to_string(), serde_json::Value::Object(page));
99        let _ = ctx.insert(
100            "site".to_string(),
101            serde_json::Value::Object(
102                site_globals
103                    .iter()
104                    .map(|(k, v)| (k.clone(), v.clone()))
105                    .collect(),
106            ),
107        );
108
109        // Inject global config variables at top level
110        for (k, v) in &self.config.globals {
111            let _ = ctx.insert(k.clone(), v.clone());
112        }
113
114        // Determine which template to use, fall back to page.html
115        let tmpl_name = if self.env.get_template(template_name).is_ok() {
116            template_name
117        } else if self.env.get_template("page.html").is_ok() {
118            "page.html"
119        } else {
120            // No matching template — return content as-is
121            return Ok(page_content.to_string());
122        };
123
124        let tmpl = self.env.get_template(tmpl_name).with_context(|| {
125            format!("Failed to load template '{tmpl_name}'")
126        })?;
127
128        tmpl.render(serde_json::Value::Object(ctx))
129            .with_context(|| format!("Failed to render template '{tmpl_name}'"))
130    }
131
132    /// Builds site-level globals from an `SsgConfig`.
133    #[must_use]
134    pub fn site_globals_from_config(
135        config: &crate::cmd::SsgConfig,
136    ) -> HashMap<String, serde_json::Value> {
137        let mut globals = HashMap::new();
138        let _ = globals.insert(
139            "name".to_string(),
140            serde_json::Value::String(config.site_name.clone()),
141        );
142        let _ = globals.insert(
143            "title".to_string(),
144            serde_json::Value::String(config.site_title.clone()),
145        );
146        let _ = globals.insert(
147            "description".to_string(),
148            serde_json::Value::String(config.site_description.clone()),
149        );
150        let _ = globals.insert(
151            "base_url".to_string(),
152            serde_json::Value::String(config.base_url.clone()),
153        );
154        let _ = globals.insert(
155            "language".to_string(),
156            serde_json::Value::String(config.language.clone()),
157        );
158        globals
159    }
160
161    /// Loads data files from a `data/` directory into the context.
162    ///
163    /// Supports `.toml`, `.json`, and `.yml`/`.yaml` files.
164    /// Files are accessible as `{{ data.filename }}` in templates.
165    ///
166    /// Example: `data/nav.toml` → `{{ data.nav.links }}`
167    #[must_use]
168    pub fn load_data_files(
169        content_dir: &std::path::Path,
170    ) -> HashMap<String, serde_json::Value> {
171        let data_dir = content_dir.parent().unwrap_or(content_dir).join("data");
172        let mut data = HashMap::new();
173
174        if !data_dir.exists() {
175            return data;
176        }
177
178        let Ok(entries) = std::fs::read_dir(&data_dir) else {
179            return data;
180        };
181
182        for entry in entries.flatten() {
183            let path = entry.path();
184            if !path.is_file() {
185                continue;
186            }
187
188            let stem = path
189                .file_stem()
190                .unwrap_or_default()
191                .to_string_lossy()
192                .to_string();
193            let ext = path
194                .extension()
195                .unwrap_or_default()
196                .to_string_lossy()
197                .to_lowercase();
198
199            let Ok(content) = std::fs::read_to_string(&path) else {
200                continue;
201            };
202
203            let value: Option<serde_json::Value> = match ext.as_str() {
204                "toml" => toml::from_str::<serde_json::Value>(&content).ok(),
205                "json" => serde_json::from_str(&content).ok(),
206                "yml" | "yaml" => serde_json::from_str(&content).ok(),
207                _ => None,
208            };
209
210            if let Some(val) = value {
211                let _ = data.insert(stem, val);
212            }
213        }
214
215        data
216    }
217}
218
219/// Custom filter: estimates reading time in minutes.
220///
221/// Usage: `{{ page.content | reading_time }}`
222/// Returns a string like "3 min read".
223#[cfg(feature = "templates")]
224fn reading_time_filter(value: String) -> String {
225    let word_count = value.split_whitespace().count();
226    let minutes = (word_count / 200).max(1);
227    format!("{minutes} min read")
228}
229
230/// Custom filter: converts a string to a URL-safe slug.
231///
232/// Usage: `{{ tag | slugify }}`
233#[cfg(feature = "templates")]
234fn slugify_filter(value: String) -> String {
235    value
236        .to_lowercase()
237        .chars()
238        .map(|c| if c.is_alphanumeric() { c } else { '-' })
239        .collect::<String>()
240        .split('-')
241        .filter(|s| !s.is_empty())
242        .collect::<Vec<_>>()
243        .join("-")
244}
245
246#[cfg(all(test, feature = "templates"))]
247#[allow(clippy::unwrap_used, clippy::expect_used)]
248mod tests {
249    use super::*;
250    use std::fs;
251    use std::path::Path;
252    use tempfile::tempdir;
253
254    fn setup_templates(dir: &Path) {
255        crate::test_support::init_logger();
256        let tera_dir = dir.join("tera");
257        fs::create_dir_all(&tera_dir).unwrap();
258
259        fs::write(
260            tera_dir.join("base.html"),
261            r#"<!DOCTYPE html>
262<html lang="{{ site.language | default("en") }}">
263<head><title>{% block title %}{{ page.title | default("Untitled") }}{% endblock %}</title>
264{% block head_extra %}{% endblock %}
265</head>
266<body>
267<main>{% block content %}{% endblock %}</main>
268<footer>{% block footer %}<p>&copy; {{ site.name | default("") }}</p>{% endblock %}</footer>
269</body>
270</html>"#,
271        )
272        .unwrap();
273
274        fs::write(
275            tera_dir.join("page.html"),
276            r#"{% extends "base.html" %}
277{% block content %}{{ page.content | safe }}{% endblock %}"#,
278        )
279        .unwrap();
280
281        fs::write(
282            tera_dir.join("post.html"),
283            r#"{% extends "base.html" %}
284{% block content %}
285<article>
286<h1>{{ page.title | default("") }}</h1>
287<time>{{ page.date | default("") }}</time>
288<p>{{ page.content | reading_time }}</p>
289{{ page.content | safe }}
290</article>
291{% endblock %}"#,
292        )
293        .unwrap();
294    }
295
296    #[test]
297    fn test_init_missing_dir() {
298        let config = TemplateConfig {
299            template_dir: PathBuf::from("/nonexistent/path"),
300            ..Default::default()
301        };
302        let result = TemplateEngine::init(config).unwrap();
303        assert!(result.is_none());
304    }
305
306    #[test]
307    fn test_init_and_render_page() {
308        let dir = tempdir().unwrap();
309        setup_templates(dir.path());
310
311        let config = TemplateConfig {
312            template_dir: dir.path().join("tera"),
313            ..Default::default()
314        };
315        let engine = TemplateEngine::init(config).unwrap().unwrap();
316
317        let mut fm = HashMap::new();
318        let _ = fm.insert(
319            "title".to_string(),
320            serde_json::Value::String("Hello".to_string()),
321        );
322
323        let mut site = HashMap::new();
324        let _ = site.insert(
325            "name".to_string(),
326            serde_json::Value::String("My Site".to_string()),
327        );
328        let _ = site.insert(
329            "language".to_string(),
330            serde_json::Value::String("en-GB".to_string()),
331        );
332
333        let result = engine
334            .render_page("page.html", "<p>Body</p>", &fm, &site)
335            .unwrap();
336
337        assert!(result.contains("Hello"));
338        assert!(result.contains("<p>Body</p>"));
339        assert!(result.contains("My Site"));
340        assert!(result.contains("en-GB"));
341    }
342
343    #[test]
344    fn test_render_post_with_reading_time() {
345        let dir = tempdir().unwrap();
346        setup_templates(dir.path());
347
348        let config = TemplateConfig {
349            template_dir: dir.path().join("tera"),
350            ..Default::default()
351        };
352        let engine = TemplateEngine::init(config).unwrap().unwrap();
353
354        let content = "word ".repeat(600); // ~3 min read
355        let mut fm = HashMap::new();
356        let _ = fm.insert(
357            "title".to_string(),
358            serde_json::Value::String("Post".to_string()),
359        );
360        let _ = fm.insert(
361            "date".to_string(),
362            serde_json::Value::String("2026-01-01".to_string()),
363        );
364
365        let site = HashMap::new();
366        let result = engine
367            .render_page("post.html", &content, &fm, &site)
368            .unwrap();
369
370        assert!(result.contains("3 min read"));
371        assert!(result.contains("<article>"));
372    }
373
374    #[test]
375    fn test_fallback_to_page_html() {
376        let dir = tempdir().unwrap();
377        setup_templates(dir.path());
378
379        let config = TemplateConfig {
380            template_dir: dir.path().join("tera"),
381            ..Default::default()
382        };
383        let engine = TemplateEngine::init(config).unwrap().unwrap();
384
385        let fm = HashMap::new();
386        let site = HashMap::new();
387        let result = engine
388            .render_page("nonexistent.html", "<p>fallback</p>", &fm, &site)
389            .unwrap();
390
391        assert!(result.contains("<p>fallback</p>"));
392    }
393
394    #[test]
395    fn test_reading_time_filter_direct() {
396        let text = "word ".repeat(400);
397        let result = reading_time_filter(text);
398        assert_eq!(result, "2 min read");
399    }
400
401    #[test]
402    fn test_slugify_filter() {
403        assert_eq!(slugify_filter("Hello World!".to_string()), "hello-world");
404        assert_eq!(slugify_filter("Rust & Web".to_string()), "rust-web");
405    }
406
407    // -------------------------------------------------------------------
408    // load_data_files — format + fallback coverage
409    // -------------------------------------------------------------------
410
411    #[test]
412    fn load_data_files_missing_data_dir_returns_empty_map() {
413        let dir = tempdir().unwrap();
414        let content = dir.path().join("content");
415        fs::create_dir_all(&content).unwrap();
416        let result = TemplateEngine::load_data_files(&content);
417        assert!(result.is_empty());
418    }
419
420    #[test]
421    fn load_data_files_parses_toml_and_json_and_yaml() {
422        let dir = tempdir().unwrap();
423        let content = dir.path().join("content");
424        fs::create_dir_all(&content).unwrap();
425        let data = dir.path().join("data");
426        fs::create_dir_all(&data).unwrap();
427
428        fs::write(data.join("site.toml"), r#"key = "toml-value""#).unwrap();
429        fs::write(data.join("nav.json"), r#"{"items": ["home", "about"]}"#)
430            .unwrap();
431        fs::write(data.join("conf.yml"), r#"{"yaml": "value"}"#).unwrap();
432        fs::write(data.join("ignored.txt"), "not parsed").unwrap();
433
434        let sub = data.join("sub");
435        fs::create_dir_all(&sub).unwrap();
436        fs::write(sub.join("inside.json"), "{}").unwrap();
437
438        let result = TemplateEngine::load_data_files(&content);
439        assert!(result.contains_key("site"));
440        assert!(result.contains_key("nav"));
441        assert!(result.contains_key("conf"));
442        assert!(!result.contains_key("ignored"));
443        assert!(!result.contains_key("sub"));
444    }
445
446    #[test]
447    fn load_data_files_skips_files_with_invalid_content() {
448        let dir = tempdir().unwrap();
449        let content = dir.path().join("content");
450        fs::create_dir_all(&content).unwrap();
451        let data = dir.path().join("data");
452        fs::create_dir_all(&data).unwrap();
453
454        fs::write(data.join("broken.toml"), "not valid toml [[[").unwrap();
455        fs::write(data.join("broken.json"), "{not valid").unwrap();
456        fs::write(data.join("good.toml"), r#"x = "y""#).unwrap();
457
458        let result = TemplateEngine::load_data_files(&content);
459        assert!(result.contains_key("good"));
460        assert!(!result.contains_key("broken"));
461    }
462
463    #[test]
464    fn load_data_files_ignores_unsupported_extensions() {
465        let dir = tempdir().unwrap();
466        let content = dir.path().join("content");
467        fs::create_dir_all(&content).unwrap();
468        let data = dir.path().join("data");
469        fs::create_dir_all(&data).unwrap();
470
471        fs::write(data.join("a.xml"), "<x/>").unwrap();
472        fs::write(data.join("b.csv"), "a,b").unwrap();
473        fs::write(data.join("c"), "no extension").unwrap();
474
475        let result = TemplateEngine::load_data_files(&content);
476        assert!(result.is_empty());
477    }
478
479    // -------------------------------------------------------------------
480    // render_page — custom globals + no-fallback branch
481    // -------------------------------------------------------------------
482
483    #[test]
484    fn render_page_injects_custom_globals_from_config() {
485        let dir = tempdir().unwrap();
486        setup_templates(dir.path());
487
488        // Write a minimal template that references the custom global.
489        fs::write(
490            dir.path().join("tera").join("branded.html"),
491            r"<p>{{ brand }}</p>",
492        )
493        .unwrap();
494
495        let config = TemplateConfig {
496            template_dir: dir.path().join("tera"),
497            globals: {
498                let mut g = HashMap::new();
499                let _ = g.insert(
500                    "brand".to_string(),
501                    serde_json::Value::String("Acme".to_string()),
502                );
503                g
504            },
505            ..Default::default()
506        };
507        let engine = TemplateEngine::init(config).unwrap().unwrap();
508
509        let result = engine
510            .render_page("branded.html", "", &HashMap::new(), &HashMap::new())
511            .unwrap();
512        assert!(result.contains("Acme"));
513    }
514
515    #[test]
516    fn render_page_no_matching_template_and_no_page_html_returns_content_as_is()
517    {
518        let dir = tempdir().unwrap();
519        let tera_dir = dir.path().join("tera");
520        fs::create_dir_all(&tera_dir).unwrap();
521        // Only write a `base.html`, NOT a `page.html`.
522        fs::write(
523            tera_dir.join("base.html"),
524            r"<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>",
525        )
526        .unwrap();
527
528        let config = TemplateConfig {
529            template_dir: tera_dir,
530            ..Default::default()
531        };
532        let engine = TemplateEngine::init(config).unwrap().unwrap();
533
534        let content = "<p>raw content</p>";
535        let result = engine
536            .render_page(
537                "nonexistent.html",
538                content,
539                &HashMap::new(),
540                &HashMap::new(),
541            )
542            .unwrap();
543        assert_eq!(result, content);
544    }
545
546    #[test]
547    fn init_with_autoescape_false() {
548        let dir = tempdir().unwrap();
549        setup_templates(dir.path());
550
551        let config = TemplateConfig {
552            template_dir: dir.path().join("tera"),
553            autoescape: false,
554            ..Default::default()
555        };
556        let engine = TemplateEngine::init(config).unwrap().unwrap();
557        let result = engine
558            .render_page(
559                "page.html",
560                "<p>x</p>",
561                &HashMap::new(),
562                &HashMap::new(),
563            )
564            .unwrap();
565        assert!(result.contains("<p>x</p>"));
566    }
567
568    #[test]
569    fn init_with_broken_template_errors_on_render() {
570        let dir = tempdir().unwrap();
571        let tera_dir = dir.path().join("tera");
572        fs::create_dir_all(&tera_dir).unwrap();
573        // Use an extends to a non-existent parent — always errors on render
574        fs::write(tera_dir.join("broken.html"), "{% extends \"nonexistent_parent.html\" %}{% block x %}{% endblock %}").unwrap();
575
576        let config = TemplateConfig {
577            template_dir: tera_dir,
578            ..Default::default()
579        };
580        // MiniJinja uses lazy loading — init succeeds
581        let engine = TemplateEngine::init(config).unwrap().unwrap();
582        // Error surfaces at render time
583        let result = engine.render_page(
584            "broken.html",
585            "",
586            &HashMap::new(),
587            &HashMap::new(),
588        );
589        assert!(result.is_err());
590    }
591
592    #[test]
593    #[cfg(unix)]
594    fn load_data_files_unreadable_file_continues_silently() {
595        let dir = tempdir().unwrap();
596        let content = dir.path().join("content");
597        fs::create_dir_all(&content).unwrap();
598        let data = dir.path().join("data");
599        fs::create_dir_all(&data).unwrap();
600
601        fs::create_dir_all(data.join("not-really.toml")).unwrap();
602        fs::write(data.join("real.toml"), r#"k = "v""#).unwrap();
603
604        let result = TemplateEngine::load_data_files(&content);
605        assert!(result.contains_key("real"));
606        assert!(!result.contains_key("not-really"));
607    }
608
609    #[test]
610    fn load_data_files_data_dir_is_a_file_returns_empty() {
611        let dir = tempdir().unwrap();
612        let content = dir.path().join("content");
613        fs::create_dir_all(&content).unwrap();
614        let data = dir.path().join("data");
615        fs::write(&data, "I am a file, not a directory").unwrap();
616
617        let result = TemplateEngine::load_data_files(&content);
618        assert!(result.is_empty());
619    }
620
621    #[test]
622    fn render_page_propagates_render_errors() {
623        let dir = tempdir().unwrap();
624        let tera_dir = dir.path().join("tera");
625        fs::create_dir_all(&tera_dir).unwrap();
626        // Undefined filter → render fails
627        fs::write(
628            tera_dir.join("broken.html"),
629            r"{{ page.title | nonexistent_filter }}",
630        )
631        .unwrap();
632
633        let config = TemplateConfig {
634            template_dir: tera_dir,
635            ..Default::default()
636        };
637        let engine = TemplateEngine::init(config).unwrap().unwrap();
638
639        let mut fm = HashMap::new();
640        let _ = fm.insert(
641            "title".to_string(),
642            serde_json::Value::String("T".to_string()),
643        );
644
645        let result =
646            engine.render_page("broken.html", "", &fm, &HashMap::new());
647        assert!(result.is_err());
648    }
649}