use std::fs;
use std::path::{Path, PathBuf};
use handlebars::Handlebars;
use serde::Serialize;
use crate::config::Config;
use crate::error::{OrbitError, PageError};
use crate::models::{CompiledPage, RenderedPage};
#[derive(Debug, Serialize)]
struct PageContext<'a> {
title: String,
site_title: &'a str,
content: &'a str,
date: Option<&'a str>,
description: Option<&'a str>,
tags: &'a [String],
}
pub struct TemplateEngine {
handlebars: Handlebars<'static>,
site_title: String,
default_layout: String,
}
impl TemplateEngine {
pub fn from_config(config: &Config) -> Result<Self, OrbitError> {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
if config.template_dir.exists() {
for entry in fs::read_dir(&config.template_dir).map_err(|source| OrbitError::Io {
path: config.template_dir.clone(),
source,
})? {
let entry = entry.map_err(|source| OrbitError::Io {
path: config.template_dir.clone(),
source,
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "hbs") {
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| OrbitError::Template("invalid template filename".into()))?;
let source = fs::read_to_string(&path).map_err(|source| OrbitError::Io {
path: path.clone(),
source,
})?;
handlebars
.register_template_string(name, source)
.map_err(|err| OrbitError::Template(err.to_string()))?;
}
}
}
Ok(Self {
handlebars,
site_title: config.title.clone(),
default_layout: config.layout.clone(),
})
}
pub fn render_page(
&self,
page: CompiledPage,
output_root: &Path,
) -> Result<RenderedPage, PageError> {
let layout = page
.source
.front_matter
.layout
.clone()
.unwrap_or_else(|| self.default_layout.clone());
let fallback_title = page
.source
.relative_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
.to_owned();
let title = page.source.front_matter.effective_title(&fallback_title);
let context = PageContext {
title: title.clone(),
site_title: &self.site_title,
content: &page.content_html,
date: page.source.front_matter.date.as_deref(),
description: page.source.front_matter.description.as_deref(),
tags: &page.source.front_matter.tags,
};
let html = self.handlebars.render(&layout, &context).map_err(|err| {
PageError::new(
&page.source.source_path,
format!("template render failed: {err}"),
)
})?;
let output_path = output_path_for(&page.source.relative_path, output_root);
Ok(RenderedPage::new(output_path, html))
}
}
fn output_path_for(relative: &Path, output_root: &Path) -> PathBuf {
let mut dest = output_root.to_path_buf();
dest.push(relative.with_extension("html"));
dest
}
use rayon::prelude::*;
pub fn render_all(
engine: &TemplateEngine,
pages: Vec<CompiledPage>,
output_root: &Path,
) -> Result<Vec<RenderedPage>, PageError> {
pages
.into_par_iter()
.map(|page| engine.render_page(page, output_root))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{FrontMatter, SourcePage};
use std::path::PathBuf;
#[test]
fn renders_with_default_layout() {
let mut config = Config::default();
let dir = tempfile::tempdir().unwrap();
config.template_dir = dir.path().to_path_buf();
config.layout = "base.hbs".to_owned();
std::fs::write(
dir.path().join("base.hbs"),
"<html><title>{{title}}</title><body>{{{content}}}</body></html>",
)
.unwrap();
let engine = TemplateEngine::from_config(&config).unwrap();
let compiled = CompiledPage::new(
SourcePage {
source_path: PathBuf::from("content/a.md"),
relative_path: PathBuf::from("a.md"),
front_matter: FrontMatter {
title: Some("Page".into()),
..Default::default()
},
body: String::new(),
},
"<p>Hi</p>".to_owned(),
);
let rendered = engine.render_page(compiled, Path::new("dist")).unwrap();
assert!(rendered.html.contains("<p>Hi</p>"));
assert!(rendered.output_path.ends_with("a.html"));
}
}