//! Tera-based template engine for runtime HTML generation. use std::path::Path; use serde::Serialize; use tera::{Context, Tera}; use crate::config::SiteConfig; use crate::content::{Content, NavItem}; use crate::error::{Error, Result}; /// Runtime template engine wrapping Tera. pub struct TemplateEngine { tera: Tera, } impl TemplateEngine { /// Load templates from a directory (glob pattern: `templates/**/*`). pub fn new(template_dir: &Path) -> Result { let pattern = template_dir.join("**/*").display().to_string(); let tera = Tera::new(&pattern).map_err(Error::TemplateLoad)?; Ok(Self { tera }) } /// Render a template by name with the given context. pub fn render(&self, template_name: &str, context: &Context) -> Result { self.tera .render(template_name, context) .map_err(|e| Error::TemplateRender { template: template_name.to_string(), source: e, }) } /// Render a standalone page (about, collab, etc.). pub fn render_page( &self, content: &Content, html_body: &str, page_path: &str, config: &SiteConfig, nav: &[NavItem], ) -> Result { let mut ctx = self.base_context(page_path, config, nav); ctx.insert("title", &content.frontmatter.title); ctx.insert("page", &FrontmatterContext::from(&content.frontmatter)); ctx.insert("content", html_body); self.render("page.html", &ctx) } /// Render a content item (blog post, project, etc.). pub fn render_content( &self, content: &Content, html_body: &str, page_path: &str, config: &SiteConfig, nav: &[NavItem], ) -> Result { let template = content .frontmatter .template .as_deref() .unwrap_or("content/default.html"); let mut ctx = self.base_context(page_path, config, nav); ctx.insert("title", &content.frontmatter.title); ctx.insert("page", &FrontmatterContext::from(&content.frontmatter)); ctx.insert("content", html_body); self.render(template, &ctx) } /// Render a section index page (blog index, projects index). pub fn render_section( &self, section: &Content, section_type: &str, items: &[ContentContext], page_path: &str, config: &SiteConfig, nav: &[NavItem], ) -> Result { let template = format!("section/{}.html", section_type); let mut ctx = self.base_context(page_path, config, nav); ctx.insert("title", §ion.frontmatter.title); ctx.insert("section", &FrontmatterContext::from(§ion.frontmatter)); ctx.insert("items", items); self.render(&template, &ctx) } /// Build base context with common variables. fn base_context(&self, page_path: &str, config: &SiteConfig, nav: &[NavItem]) -> Context { let mut ctx = Context::new(); ctx.insert("config", &ConfigContext::from(config)); ctx.insert("nav", nav); ctx.insert("page_path", page_path); ctx.insert("prefix", &relative_prefix(page_path)); // Trimmed base_url for canonical links ctx.insert("base_url", config.base_url.trim_end_matches('/')); ctx } } /// Compute relative path prefix based on page depth. fn relative_prefix(page_path: &str) -> String { let depth = page_path.matches('/').count().saturating_sub(1); if depth == 0 { ".".to_string() } else { (0..depth).map(|_| "..").collect::>().join("/") } } // ============================================================================ // Context structs for Tera serialization // ============================================================================ /// Site config context for templates. #[derive(Serialize)] pub struct ConfigContext { pub title: String, pub author: String, pub base_url: String, } impl From<&SiteConfig> for ConfigContext { fn from(config: &SiteConfig) -> Self { Self { title: config.title.clone(), author: config.author.clone(), base_url: config.base_url.clone(), } } } /// Frontmatter context for templates. #[derive(Serialize)] pub struct FrontmatterContext { pub title: String, pub description: Option, pub date: Option, pub tags: Vec, pub weight: Option, pub link_to: Option, } impl From<&crate::content::Frontmatter> for FrontmatterContext { fn from(fm: &crate::content::Frontmatter) -> Self { Self { title: fm.title.clone(), description: fm.description.clone(), date: fm.date.clone(), tags: fm.tags.clone(), weight: fm.weight, link_to: fm.link_to.clone(), } } } /// Content item context for section listings. #[derive(Serialize)] pub struct ContentContext { pub frontmatter: FrontmatterContext, pub body: String, pub slug: String, pub path: String, } impl ContentContext { pub fn from_content(content: &Content, content_dir: &Path) -> Self { Self { frontmatter: FrontmatterContext::from(&content.frontmatter), body: content.body.clone(), slug: content.slug.clone(), path: format!("/{}", content.output_path(content_dir).display()), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_relative_prefix_root() { assert_eq!(relative_prefix("/index.html"), "."); } #[test] fn test_relative_prefix_depth_1() { assert_eq!(relative_prefix("/blog/index.html"), ".."); } #[test] fn test_relative_prefix_depth_2() { assert_eq!(relative_prefix("/blog/posts/foo.html"), "../.."); } }