Skip to main content

ssg/
template_plugin.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Template rendering plugin.
5//!
6//! Post-processes compiled HTML through templates, enabling
7//! template inheritance, conditionals, loops, and filters.
8
9#[cfg(feature = "templates")]
10use crate::{
11    frontmatter,
12    plugin::{Plugin, PluginContext},
13    template_engine::{TemplateConfig, TemplateEngine},
14    MAX_DIR_DEPTH,
15};
16#[cfg(feature = "templates")]
17use anyhow::Result;
18#[cfg(feature = "templates")]
19use std::{
20    collections::HashMap,
21    fs,
22    path::{Path, PathBuf},
23};
24
25/// Plugin that post-processes compiled HTML through templates.
26///
27/// Runs in the `after_compile` phase. For each HTML file in `site_dir`:
28/// 1. Reads the companion `.meta.json` sidecar (from frontmatter extraction)
29/// 2. Determines the layout from frontmatter (`layout` field, default: `page`)
30/// 3. Renders the HTML through the template chain
31/// 4. Writes the rendered result back to the same file
32///
33/// Falls back gracefully if no templates directory exists.
34#[cfg(feature = "templates")]
35#[derive(Debug)]
36pub struct TemplatePlugin {
37    config: TemplateConfig,
38}
39
40#[cfg(feature = "templates")]
41impl TemplatePlugin {
42    /// Creates a new `TemplatePlugin` with the given configuration.
43    #[must_use]
44    pub const fn new(config: TemplateConfig) -> Self {
45        Self { config }
46    }
47
48    /// Creates a `TemplatePlugin` that looks for templates in the standard
49    /// `templates/tera/` subdirectory of the template dir.
50    #[must_use]
51    pub fn from_template_dir(template_dir: &Path) -> Self {
52        Self {
53            config: TemplateConfig {
54                template_dir: template_dir.join("tera"),
55                ..Default::default()
56            },
57        }
58    }
59}
60
61#[cfg(feature = "templates")]
62impl Plugin for TemplatePlugin {
63    fn name(&self) -> &'static str {
64        "templates"
65    }
66
67    fn before_compile(&self, ctx: &PluginContext) -> Result<()> {
68        // Emit .meta.json sidecars for all markdown content
69        let sidecar_dir = ctx.build_dir.join(".meta");
70        let count = frontmatter::emit_sidecars(&ctx.content_dir, &sidecar_dir)?;
71        if count > 0 {
72            log::info!("[templates] Emitted {count} frontmatter sidecar(s)");
73        }
74        Ok(())
75    }
76
77    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
78        let Some(engine) = TemplateEngine::init(self.config.clone())? else {
79            log::info!(
80                "[templates] No templates at {}, skipping",
81                self.config.template_dir.display()
82            );
83            return Ok(());
84        };
85
86        // Build site-level globals from config
87        let mut site_globals = ctx
88            .config
89            .as_ref()
90            .map(TemplateEngine::site_globals_from_config)
91            .unwrap_or_default();
92
93        // Load data files (data/*.toml, data/*.json) into context
94        let data_files = TemplateEngine::load_data_files(&ctx.content_dir);
95        if !data_files.is_empty() {
96            let _ = site_globals.insert(
97                "data".to_string(),
98                serde_json::Value::Object(data_files.into_iter().collect()),
99            );
100        }
101
102        let sidecar_dir = ctx.build_dir.join(".meta");
103        let html_files = collect_html_files(&ctx.site_dir)?;
104
105        let mut rendered = 0usize;
106        for html_path in &html_files {
107            let content = fs::read_to_string(html_path)?;
108
109            // Read frontmatter sidecar
110            let fm = read_frontmatter_for_html(
111                html_path,
112                &ctx.site_dir,
113                &sidecar_dir,
114            );
115
116            // Determine template from `layout` field
117            let layout =
118                fm.get("layout").and_then(|v| v.as_str()).unwrap_or("page");
119            let template_name = format!("{layout}.html");
120
121            match engine.render_page(
122                &template_name,
123                &content,
124                &fm,
125                &site_globals,
126            ) {
127                Ok(output) => {
128                    fs::write(html_path, output)?;
129                    rendered += 1;
130                }
131                Err(e) => {
132                    log::warn!(
133                        "[templates] Failed to render {}: {e}",
134                        html_path.display()
135                    );
136                }
137            }
138        }
139
140        if rendered > 0 {
141            log::info!("[templates] Rendered {rendered} page(s)");
142        }
143        Ok(())
144    }
145}
146
147/// Reads frontmatter for an HTML file, trying sidecar then falling back to empty.
148#[cfg(feature = "templates")]
149fn read_frontmatter_for_html(
150    html_path: &Path,
151    site_dir: &Path,
152    sidecar_dir: &Path,
153) -> HashMap<String, serde_json::Value> {
154    let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
155    let sidecar = sidecar_dir.join(rel).with_extension("meta.json");
156    if sidecar.exists() {
157        if let Ok(content) = fs::read_to_string(&sidecar) {
158            if let Ok(meta) = serde_json::from_str(&content) {
159                return meta;
160            }
161        }
162    }
163    HashMap::new()
164}
165
166/// Recursively collects `.html` files (delegates to `crate::walk`).
167#[cfg(feature = "templates")]
168fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
169    crate::walk::walk_files_bounded_depth(dir, "html", MAX_DIR_DEPTH)
170}
171
172#[cfg(all(test, feature = "templates"))]
173#[allow(clippy::unwrap_used, clippy::expect_used)]
174mod tests {
175    use super::*;
176    use crate::cmd::SsgConfig;
177    use crate::test_support::init_logger;
178    use std::fs;
179    use tempfile::{tempdir, TempDir};
180
181    // -------------------------------------------------------------------
182    // Test fixtures
183    // -------------------------------------------------------------------
184
185    fn layout() -> (TempDir, PathBuf, PathBuf, PathBuf, PathBuf) {
186        init_logger();
187        let dir = tempdir().expect("tempdir");
188        let content = dir.path().join("content");
189        let build = dir.path().join("build");
190        let site = dir.path().join("site");
191        let templates = dir.path().join("templates/tera");
192        for d in [&content, &build, &site, &templates] {
193            fs::create_dir_all(d).expect("mkdir");
194        }
195        (dir, content, build, site, templates)
196    }
197
198    fn make_config(root: &Path) -> SsgConfig {
199        SsgConfig {
200            site_name: "Test".to_string(),
201            site_title: "Test Site".to_string(),
202            site_description: "Desc".to_string(),
203            base_url: "http://localhost".to_string(),
204            language: "en-GB".to_string(),
205            content_dir: root.join("content"),
206            output_dir: root.join("build"),
207            template_dir: root.join("templates"),
208            serve_dir: None,
209            i18n: None,
210        }
211    }
212
213    fn setup_project(dir: &Path) {
214        let content = dir.join("content");
215        let build = dir.join("build");
216        let site = dir.join("site");
217        let templates = dir.join("templates/tera");
218        fs::create_dir_all(&content).unwrap();
219        fs::create_dir_all(&build).unwrap();
220        fs::create_dir_all(&site).unwrap();
221        fs::create_dir_all(&templates).unwrap();
222
223        fs::write(
224            templates.join("base.html"),
225            r#"<!DOCTYPE html>
226<html><head><title>{{ page.title | default("") }}</title></head>
227<body>{% block content %}{% endblock %}</body></html>"#,
228        )
229        .unwrap();
230
231        fs::write(
232            templates.join("page.html"),
233            r#"{% extends "base.html" %}
234{% block content %}{{ page.content | safe }}{% endblock %}"#,
235        )
236        .unwrap();
237
238        fs::write(
239            content.join("index.md"),
240            "---\ntitle: Home\nlayout: page\n---\n# Welcome\n",
241        )
242        .unwrap();
243
244        fs::write(site.join("index.html"), "<h1>Welcome</h1>").unwrap();
245
246        let meta_dir = build.join(".meta");
247        fs::create_dir_all(&meta_dir).unwrap();
248        fs::write(
249            meta_dir.join("index.meta.json"),
250            r#"{"title": "Home", "layout": "page"}"#,
251        )
252        .unwrap();
253    }
254
255    #[test]
256    fn test_template_plugin_renders() {
257        let dir = tempdir().unwrap();
258        setup_project(dir.path());
259
260        let plugin = TemplatePlugin::new(TemplateConfig {
261            template_dir: dir.path().join("templates/tera"),
262            ..Default::default()
263        });
264
265        let config = SsgConfig {
266            site_name: "Test".to_string(),
267            site_title: "Test Site".to_string(),
268            site_description: "Desc".to_string(),
269            base_url: "http://localhost".to_string(),
270            language: "en-GB".to_string(),
271            content_dir: dir.path().join("content"),
272            output_dir: dir.path().join("build"),
273            template_dir: dir.path().join("templates"),
274            serve_dir: None,
275            i18n: None,
276        };
277
278        let content_dir = config.content_dir.clone();
279        let output_dir = config.output_dir.clone();
280        let template_dir = config.template_dir.clone();
281        let site = dir.path().join("site");
282        let ctx = PluginContext::with_config(
283            &content_dir,
284            &output_dir,
285            &site,
286            &template_dir,
287            config,
288        );
289
290        plugin.after_compile(&ctx).unwrap();
291
292        let output =
293            fs::read_to_string(dir.path().join("site/index.html")).unwrap();
294        assert!(output.contains("<!DOCTYPE html>"));
295        assert!(output.contains("Home"));
296        assert!(output.contains("<h1>Welcome</h1>"));
297    }
298
299    #[test]
300    fn test_template_plugin_skips_missing_templates() {
301        let dir = tempdir().unwrap();
302        let site = dir.path().join("site");
303        fs::create_dir_all(&site).unwrap();
304        fs::write(site.join("index.html"), "<p>hello</p>").unwrap();
305
306        let plugin = TemplatePlugin::new(TemplateConfig {
307            template_dir: dir.path().join("nonexistent"),
308            ..Default::default()
309        });
310
311        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
312
313        plugin.after_compile(&ctx).unwrap();
314
315        let output = fs::read_to_string(site.join("index.html")).unwrap();
316        assert_eq!(output, "<p>hello</p>");
317    }
318
319    #[test]
320    fn name_returns_templates_identifier() {
321        let plugin = TemplatePlugin::new(TemplateConfig::default());
322        assert_eq!(plugin.name(), "templates");
323    }
324
325    #[test]
326    fn new_stores_supplied_config() {
327        let cfg = TemplateConfig {
328            template_dir: std::env::temp_dir().join("ssg_template_fake"),
329            ..Default::default()
330        };
331        let plugin = TemplatePlugin::new(cfg.clone());
332        assert_eq!(plugin.config.template_dir, cfg.template_dir);
333    }
334
335    #[test]
336    fn from_template_dir_nests_under_tera_subdirectory() {
337        let plugin =
338            TemplatePlugin::from_template_dir(Path::new("/my/templates"));
339        assert!(plugin.config.template_dir.ends_with("templates/tera"));
340    }
341
342    #[test]
343    fn before_compile_emits_sidecars_from_content_markdown() {
344        let (_tmp, content, build, _site, templates) = layout();
345        fs::write(content.join("index.md"), "---\ntitle: Test\n---\nbody")
346            .unwrap();
347
348        let plugin = TemplatePlugin::new(TemplateConfig {
349            template_dir: templates,
350            ..Default::default()
351        });
352        let ctx = PluginContext::new(&content, &build, &content, &content);
353
354        plugin.before_compile(&ctx).unwrap();
355        assert!(build.join(".meta").join("index.meta.json").exists());
356    }
357
358    #[test]
359    fn before_compile_no_markdown_files_still_returns_ok() {
360        let (_tmp, content, build, _site, templates) = layout();
361        let plugin = TemplatePlugin::new(TemplateConfig {
362            template_dir: templates,
363            ..Default::default()
364        });
365        let ctx = PluginContext::new(&content, &build, &content, &content);
366        plugin.before_compile(&ctx).unwrap();
367    }
368
369    #[test]
370    fn after_compile_without_config_uses_empty_site_globals() {
371        let dir = tempdir().unwrap();
372        setup_project(dir.path());
373
374        let plugin = TemplatePlugin::new(TemplateConfig {
375            template_dir: dir.path().join("templates/tera"),
376            ..Default::default()
377        });
378        let ctx = PluginContext::new(
379            &dir.path().join("content"),
380            &dir.path().join("build"),
381            &dir.path().join("site"),
382            &dir.path().join("templates"),
383        );
384
385        plugin.after_compile(&ctx).unwrap();
386        let output =
387            fs::read_to_string(dir.path().join("site").join("index.html"))
388                .unwrap();
389        assert!(output.contains("<!DOCTYPE html>"));
390    }
391
392    #[test]
393    fn after_compile_loads_data_files_into_context() {
394        let dir = tempdir().unwrap();
395        setup_project(dir.path());
396
397        let data = dir.path().join("data");
398        fs::create_dir_all(&data).unwrap();
399        fs::write(data.join("nav.toml"), r#"site = "demo""#).unwrap();
400
401        let plugin = TemplatePlugin::new(TemplateConfig {
402            template_dir: dir.path().join("templates/tera"),
403            ..Default::default()
404        });
405        let config = make_config(dir.path());
406        let ctx = PluginContext::with_config(
407            &config.content_dir.clone(),
408            &config.output_dir.clone(),
409            &dir.path().join("site"),
410            &config.template_dir.clone(),
411            config,
412        );
413
414        plugin.after_compile(&ctx).unwrap();
415        let output =
416            fs::read_to_string(dir.path().join("site").join("index.html"))
417                .unwrap();
418        assert!(output.contains("<!DOCTYPE html>"));
419    }
420
421    #[test]
422    fn after_compile_unknown_layout_does_not_propagate_error() {
423        let dir = tempdir().unwrap();
424        setup_project(dir.path());
425
426        let meta_dir = dir.path().join("build").join(".meta");
427        fs::write(
428            meta_dir.join("index.meta.json"),
429            r#"{"title": "Home", "layout": "unknown_layout_999"}"#,
430        )
431        .unwrap();
432
433        let plugin = TemplatePlugin::new(TemplateConfig {
434            template_dir: dir.path().join("templates/tera"),
435            ..Default::default()
436        });
437        let ctx = PluginContext::new(
438            &dir.path().join("content"),
439            &dir.path().join("build"),
440            &dir.path().join("site"),
441            &dir.path().join("templates"),
442        );
443
444        plugin
445            .after_compile(&ctx)
446            .expect("render failure must not propagate");
447    }
448
449    #[test]
450    fn after_compile_default_layout_is_page_when_missing_field() {
451        let dir = tempdir().unwrap();
452        setup_project(dir.path());
453
454        let meta_dir = dir.path().join("build").join(".meta");
455        fs::write(meta_dir.join("index.meta.json"), r#"{"title": "Home"}"#)
456            .unwrap();
457
458        let plugin = TemplatePlugin::new(TemplateConfig {
459            template_dir: dir.path().join("templates/tera"),
460            ..Default::default()
461        });
462        let ctx = PluginContext::new(
463            &dir.path().join("content"),
464            &dir.path().join("build"),
465            &dir.path().join("site"),
466            &dir.path().join("templates"),
467        );
468
469        plugin.after_compile(&ctx).unwrap();
470        let out =
471            fs::read_to_string(dir.path().join("site").join("index.html"))
472                .unwrap();
473        assert!(out.contains("<!DOCTYPE html>"));
474    }
475
476    // -------------------------------------------------------------------
477    // read_frontmatter_for_html — three branches
478    // -------------------------------------------------------------------
479
480    #[test]
481    fn read_frontmatter_for_html_direct_sidecar_match() {
482        let dir = tempdir().unwrap();
483        let site = dir.path().join("site");
484        let sidecars = dir.path().join(".meta");
485        fs::create_dir_all(&site).unwrap();
486        fs::create_dir_all(&sidecars).unwrap();
487
488        let html = site.join("post.html");
489        fs::write(&html, "").unwrap();
490        fs::write(sidecars.join("post.meta.json"), r#"{"title": "Direct"}"#)
491            .unwrap();
492
493        let meta = read_frontmatter_for_html(&html, &site, &sidecars);
494        assert_eq!(meta.get("title").and_then(|v| v.as_str()), Some("Direct"));
495    }
496
497    #[test]
498    fn read_frontmatter_for_html_invalid_sidecar_returns_empty() {
499        let dir = tempdir().unwrap();
500        let site = dir.path().join("site");
501        let sidecars = dir.path().join(".meta");
502        fs::create_dir_all(&site).unwrap();
503        fs::create_dir_all(&sidecars).unwrap();
504
505        let html = site.join("post.html");
506        fs::write(&html, "").unwrap();
507        fs::write(sidecars.join("post.meta.json"), "{not valid").unwrap();
508
509        let meta = read_frontmatter_for_html(&html, &site, &sidecars);
510        assert!(meta.is_empty());
511    }
512
513    #[test]
514    fn read_frontmatter_for_html_no_match_returns_empty_map() {
515        let dir = tempdir().unwrap();
516        let site = dir.path().join("site");
517        let sidecars = dir.path().join(".meta");
518        fs::create_dir_all(&site).unwrap();
519        fs::create_dir_all(&sidecars).unwrap();
520
521        let html = site.join("ghost.html");
522        fs::write(&html, "").unwrap();
523
524        let meta = read_frontmatter_for_html(&html, &site, &sidecars);
525        assert!(meta.is_empty());
526    }
527
528    // -------------------------------------------------------------------
529    // collect_html_files
530    // -------------------------------------------------------------------
531
532    #[test]
533    fn collect_html_files_filters_non_html_extensions() {
534        let dir = tempdir().unwrap();
535        fs::write(dir.path().join("a.html"), "").unwrap();
536        fs::write(dir.path().join("b.css"), "").unwrap();
537        fs::write(dir.path().join("c.js"), "").unwrap();
538
539        let files = collect_html_files(dir.path()).unwrap();
540        assert_eq!(files.len(), 1);
541    }
542
543    #[test]
544    fn collect_html_files_recurses_into_subdirectories() {
545        let dir = tempdir().unwrap();
546        let nested = dir.path().join("blog").join("2026");
547        fs::create_dir_all(&nested).unwrap();
548        fs::write(dir.path().join("index.html"), "").unwrap();
549        fs::write(nested.join("post.html"), "").unwrap();
550
551        let files = collect_html_files(dir.path()).unwrap();
552        assert_eq!(files.len(), 2);
553    }
554
555    #[test]
556    fn collect_html_files_returns_empty_for_missing_directory() {
557        let dir = tempdir().unwrap();
558        let result = collect_html_files(&dir.path().join("missing")).unwrap();
559        assert!(result.is_empty());
560    }
561}