use anyhow::{Context, Result};
use log::{debug, trace};
use regex::Regex;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tera::Tera;
use crate::config::{Config, PageDef};
use crate::data::register_data_functions;
use crate::git::register_git_functions;
use crate::lua::AssetManifest;
use crate::tracker::SharedTracker;
static EXTENDS_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"\{%\s*extends\s+["']([^"']+)["']\s*%\}"#).unwrap());
static INCLUDE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"\{%\s*include\s+["']([^"']+)["']\s*"#).unwrap());
#[derive(Debug, Clone, Default)]
pub struct TemplateDeps {
pub template_files: HashMap<String, PathBuf>,
pub dependencies: HashMap<String, HashSet<String>>,
pub dependents: HashMap<String, HashSet<String>>,
}
impl TemplateDeps {
pub fn build(template_dir: &Path) -> Result<Self> {
let mut deps = Self::default();
let pattern = format!("{}/**/*", template_dir.to_string_lossy());
for entry in glob::glob(&pattern)? {
let path = entry?;
if path.is_file() {
if let Ok(rel_path) = path.strip_prefix(template_dir) {
let template_name = rel_path.to_string_lossy().to_string();
deps.template_files
.insert(template_name.clone(), path.clone());
if let Ok(content) = std::fs::read_to_string(&path) {
let mut template_deps = HashSet::new();
for cap in EXTENDS_RE.captures_iter(&content) {
if let Some(m) = cap.get(1) {
template_deps.insert(m.as_str().to_string());
}
}
for cap in INCLUDE_RE.captures_iter(&content) {
if let Some(m) = cap.get(1) {
template_deps.insert(m.as_str().to_string());
}
}
deps.dependencies.insert(template_name, template_deps);
}
}
}
}
for (template, template_deps) in &deps.dependencies {
for dep in template_deps {
deps.dependents
.entry(dep.clone())
.or_default()
.insert(template.clone());
}
}
debug!(
"Built template dependency graph: {} templates",
deps.template_files.len()
);
trace!("Template dependencies: {:?}", deps.dependencies);
Ok(deps)
}
pub fn get_affected_templates(&self, changed_template: &str) -> HashSet<String> {
let mut affected = HashSet::new();
let mut to_process = vec![changed_template.to_string()];
while let Some(template) = to_process.pop() {
if affected.insert(template.clone()) {
if let Some(dependents) = self.dependents.get(&template) {
for dep in dependents {
if !affected.contains(dep) {
to_process.push(dep.clone());
}
}
}
}
}
affected
}
pub fn get_file_path(&self, template_name: &str) -> Option<&PathBuf> {
self.template_files.get(template_name)
}
pub fn find_template_by_path(&self, path: &Path) -> Option<&String> {
self.template_files
.iter()
.find(|(_, p)| *p == path)
.map(|(name, _)| name)
}
}
pub struct Templates {
tera: Tera,
deps: TemplateDeps,
}
impl Templates {
pub fn new(
template_dir: &Path,
tracker: Option<SharedTracker>,
asset_manifest: Option<AssetManifest>,
) -> Result<Self> {
debug!("Loading templates from {:?}", template_dir);
let deps = TemplateDeps::build(template_dir)?;
if let Some(ref tracker) = tracker {
for path in deps.template_files.values() {
if let Ok(content) = std::fs::read(path) {
tracker.record_read(path.clone(), &content);
}
}
}
let template_dir_str = template_dir.to_string_lossy();
let pattern = format!("{}/**/*", template_dir_str);
let mut tera = Tera::new(&pattern)
.with_context(|| format!("Failed to load templates from {}", template_dir_str))?;
register_git_functions(&mut tera);
register_data_functions(&mut tera, tracker, asset_manifest);
let template_count = tera.get_template_names().count();
debug!("Loaded {} templates", template_count);
trace!(
"Available templates: {:?}",
tera.get_template_names().collect::<Vec<_>>()
);
Ok(Self { tera, deps })
}
pub fn deps(&self) -> &TemplateDeps {
&self.deps
}
pub fn render_page(
&self,
config: &Config,
page: &PageDef,
global_data: &serde_json::Value,
html_content: Option<&str>,
) -> Result<String> {
let mut context = tera::Context::new();
let ctx = CtxRoot {
site: SiteContext::from(config),
global: global_data.clone(),
page: PageContext::from_page_def(config, page, html_content),
};
context.insert("ctx", &ctx);
context.insert("site", &ctx.site);
context.insert("global", &ctx.global);
context.insert("page", &ctx.page);
let raw_content = html_content
.map(|s| s.to_string())
.or_else(|| page.html.clone());
let processed_content = if let Some(ref content) = raw_content {
if content.contains("{{") || content.contains("{%") {
trace!(
"Pre-processing content with Tera syntax for page '{}'",
page.path
);
match Tera::one_off(content, &context, false) {
Ok(rendered) => Some(rendered),
Err(e) => {
debug!("Failed to pre-process content Tera syntax: {}", e);
raw_content
}
}
} else {
raw_content
}
} else {
None
};
if let Some(ref content) = processed_content {
context.insert("content", content);
}
let template = page
.template
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Page '{}' has no template", page.path))?;
self.tera.render(template, &context).with_context(|| {
format!(
"Failed to render page '{}' with template '{}'",
page.path, template
)
})
}
}
#[derive(Serialize)]
struct CtxRoot {
site: SiteContext,
global: serde_json::Value,
page: PageContext,
}
#[derive(Serialize, Clone)]
struct SiteContext {
title: String,
description: String,
base_url: String,
author: String,
twitter_handle: Option<String>,
default_og_image: Option<String>,
}
impl From<&Config> for SiteContext {
fn from(config: &Config) -> Self {
Self {
title: config.site.title.clone(),
description: config.site.description.clone(),
base_url: config.site.base_url.clone(),
author: config.site.author.clone(),
twitter_handle: config.seo.twitter_handle.clone(),
default_og_image: config.seo.default_og_image.clone(),
}
}
}
#[derive(Serialize)]
struct PageContext {
title: String,
description: String,
url: String,
path: String,
image: Option<String>,
content: Option<String>,
data: Option<serde_json::Value>,
}
impl PageContext {
fn from_page_def(config: &Config, page: &PageDef, html_content: Option<&str>) -> Self {
Self {
title: page.title.clone().unwrap_or_default(),
description: page
.description
.clone()
.unwrap_or_else(|| config.site.description.clone()),
url: format!(
"{}{}",
config.site.base_url.trim_end_matches('/'),
page.path
),
path: page.path.clone(),
image: page
.image
.clone()
.or_else(|| config.seo.default_og_image.clone()),
content: html_content
.map(|s| s.to_string())
.or_else(|| page.html.clone()),
data: page.data.clone(),
}
}
}