feat(templates): complete Tera migration, remove maud

Fully migrate from compile-time maud templates to runtime Tera:
- Rewrote main.rs to use TemplateEngine and discover_sections()
- Replaced hardcoded blog/projects with generic section loop
- Added Clone derive to Frontmatter and Content
- Fixed section_type dispatch via Section struct
- Deleted src/templates.rs, removed maud dependency

Users can now add sections without code changes.
This commit is contained in:
Timothy DeHerrera
2026-01-31 15:10:39 -07:00
parent 244b0ce85b
commit e200e94583
7 changed files with 103 additions and 435 deletions

46
Cargo.lock generated
View File

@@ -816,28 +816,6 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "maud"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
dependencies = [
"itoa",
"maud_macros",
]
[[package]]
name = "maud_macros"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "memchr"
version = "2.7.6"
@@ -868,7 +846,6 @@ dependencies = [
"gray_matter",
"katex-rs",
"lightningcss",
"maud",
"mermaid-rs-renderer",
"pulldown-cmark",
"serde",
@@ -1163,29 +1140,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"

View File

@@ -7,7 +7,6 @@ version = "0.1.0"
[dependencies]
gray_matter = "0.2"
maud = "0.26"
pulldown-cmark = "0.12"
thiserror = "2"
walkdir = "2"

View File

@@ -31,7 +31,7 @@ pub struct NavItem {
}
/// Parsed frontmatter from a content file.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Frontmatter {
pub title: String,
pub description: Option<String>,
@@ -50,7 +50,7 @@ pub struct Frontmatter {
}
/// A content item ready for rendering.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Content {
pub kind: ContentKind,
pub frontmatter: Frontmatter,

View File

@@ -12,10 +12,10 @@ mod math;
mod mermaid;
mod render;
mod template_engine;
mod templates;
use crate::content::{discover_nav, Content, ContentKind, NavItem};
use crate::content::{discover_nav, discover_sections, Content, ContentKind, NavItem};
use crate::error::{Error, Result};
use crate::template_engine::{ContentContext, TemplateEngine};
use std::fs;
use std::path::Path;
@@ -31,6 +31,7 @@ fn run() -> Result<()> {
let output_dir = Path::new("public");
let static_dir = Path::new("static");
let config_path = Path::new("site.toml");
let template_dir = Path::new("templates");
if !content_dir.exists() {
return Err(Error::ContentDirNotFound(content_dir.to_path_buf()));
@@ -39,99 +40,103 @@ fn run() -> Result<()> {
// Load site configuration
let config = config::SiteConfig::load(config_path)?;
// Load Tera templates
let engine = TemplateEngine::new(template_dir)?;
// Discover navigation from filesystem
let nav = discover_nav(content_dir)?;
// 0. Copy static assets
copy_static_assets(static_dir, output_dir)?;
// 1. Process blog posts
let mut posts = process_blog_posts(content_dir, output_dir, &config, &nav)?;
// 1. Discover and process all sections
let sections = discover_sections(content_dir)?;
let mut all_posts = Vec::new(); // For feed generation
// 2. Generate blog index (sorted by date, newest first)
posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
generate_blog_index(output_dir, &posts, &config, &nav)?;
for section in &sections {
eprintln!("processing section: {}", section.name);
// 2b. Generate Atom feed
generate_feed(output_dir, &posts, &config, content_dir)?;
// Collect and sort items in this section
let mut items = section.collect_items()?;
// 3. Process standalone pages (discovered dynamically)
process_pages(content_dir, output_dir, &config, &nav)?;
// 4. Process projects and generate project index
let mut projects = process_projects(content_dir)?;
projects.sort_by(|a, b| {
// Sort based on section type
match section.section_type.as_str() {
"blog" => {
// Blog: sort by date, newest first
items.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
all_posts.extend(items.iter().cloned());
}
"projects" => {
// Projects: sort by weight
items.sort_by(|a, b| {
a.frontmatter
.weight
.unwrap_or(99)
.cmp(&b.frontmatter.weight.unwrap_or(99))
});
generate_projects_index(output_dir, &projects, &config, &nav)?;
// 5. Generate homepage
generate_homepage(content_dir, output_dir, &config, &nav)?;
eprintln!("done!");
Ok(())
}
/// Process all blog posts in content/blog/
fn process_blog_posts(
content_dir: &Path,
output_dir: &Path,
config: &config::SiteConfig,
nav: &[NavItem],
) -> Result<Vec<Content>> {
let blog_dir = content_dir.join("blog");
let mut posts = Vec::new();
for entry in walkdir::WalkDir::new(&blog_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.path().file_name().is_some_and(|n| n != "_index.md")
})
{
let path = entry.path();
eprintln!("processing: {}", path.display());
let content = Content::from_path(path, ContentKind::Post)?;
let html_body = render::markdown_to_html(&content.body);
let page_path = format!("/{}", content.output_path(content_dir).display());
let page =
templates::render_post(&content.frontmatter, &html_body, &page_path, config, nav);
write_output(output_dir, content_dir, &content, page.into_string())?;
posts.push(content);
}
_ => {
// Default: sort by weight then title
items.sort_by(|a, b| {
a.frontmatter
.weight
.unwrap_or(50)
.cmp(&b.frontmatter.weight.unwrap_or(50))
.then_with(|| a.frontmatter.title.cmp(&b.frontmatter.title))
});
}
}
Ok(posts)
}
// Render individual content pages (for blog posts)
if section.section_type == "blog" {
for item in &items {
eprintln!(" processing: {}", item.slug);
let html_body = render::markdown_to_html(&item.body);
let page_path = format!("/{}", item.output_path(content_dir).display());
let html = engine.render_content(item, &html_body, &page_path, &config, &nav)?;
write_output(output_dir, content_dir, item, html)?;
}
}
/// Generate the blog listing page
fn generate_blog_index(
output_dir: &Path,
posts: &[Content],
config: &config::SiteConfig,
nav: &[NavItem],
) -> Result<()> {
let out_path = output_dir.join("blog/index.html");
eprintln!("generating: {}", out_path.display());
let page = templates::render_blog_index("Blog", posts, "/blog/index.html", config, nav);
// Render section index
let page_path = format!("/{}/index.html", section.name);
let item_contexts: Vec<_> = items
.iter()
.map(|c| ContentContext::from_content(c, content_dir))
.collect();
let html = engine.render_section(
&section.index,
&section.section_type,
&item_contexts,
&page_path,
&config,
&nav,
)?;
let out_path = output_dir.join(&section.name).join("index.html");
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
path: out_path.parent().unwrap().to_path_buf(),
source: e,
})?;
fs::write(&out_path, page.into_string()).map_err(|e| Error::WriteFile {
fs::write(&out_path, html).map_err(|e| Error::WriteFile {
path: out_path.clone(),
source: e,
})?;
eprintln!("{}", out_path.display());
}
// 2. Generate Atom feed (blog posts only)
if !all_posts.is_empty() {
generate_feed(output_dir, &all_posts, &config, content_dir)?;
}
// 3. Process standalone pages (discovered dynamically)
process_pages(content_dir, output_dir, &config, &nav, &engine)?;
// 4. Generate homepage
generate_homepage(content_dir, output_dir, &config, &nav, &engine)?;
eprintln!("done!");
Ok(())
}
@@ -162,6 +167,7 @@ fn process_pages(
output_dir: &Path,
config: &config::SiteConfig,
nav: &[NavItem],
engine: &TemplateEngine,
) -> Result<()> {
// Dynamically discover top-level .md files (except _index.md)
let entries = fs::read_dir(content_dir).map_err(|e| Error::ReadFile {
@@ -180,76 +186,28 @@ fn process_pages(
let content = Content::from_path(&path, ContentKind::Page)?;
let html_body = render::markdown_to_html(&content.body);
let page_path = format!("/{}", content.output_path(content_dir).display());
let page =
templates::render_page(&content.frontmatter, &html_body, &page_path, config, nav);
let html = engine.render_page(&content, &html_body, &page_path, config, nav)?;
write_output(output_dir, content_dir, &content, page.into_string())?;
write_output(output_dir, content_dir, &content, html)?;
}
}
Ok(())
}
/// Load all project cards (without writing individual pages)
fn process_projects(content_dir: &Path) -> Result<Vec<Content>> {
let projects_dir = content_dir.join("projects");
let mut projects = Vec::new();
for entry in walkdir::WalkDir::new(&projects_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().is_some_and(|ext| ext == "md")
&& e.path().file_name().is_some_and(|n| n != "_index.md")
})
{
let content = Content::from_path(entry.path(), ContentKind::Project)?;
projects.push(content);
}
Ok(projects)
}
/// Generate the projects listing page
fn generate_projects_index(
output_dir: &Path,
projects: &[Content],
config: &config::SiteConfig,
nav: &[NavItem],
) -> Result<()> {
let out_path = output_dir.join("projects/index.html");
eprintln!("generating: {}", out_path.display());
let page =
templates::render_projects_index("Projects", projects, "/projects/index.html", config, nav);
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
path: out_path.parent().unwrap().to_path_buf(),
source: e,
})?;
fs::write(&out_path, page.into_string()).map_err(|e| Error::WriteFile {
path: out_path.clone(),
source: e,
})?;
eprintln!("{}", out_path.display());
Ok(())
}
/// Generate the homepage from content/_index.md
fn generate_homepage(
content_dir: &Path,
output_dir: &Path,
config: &config::SiteConfig,
nav: &[NavItem],
engine: &TemplateEngine,
) -> Result<()> {
let index_path = content_dir.join("_index.md");
eprintln!("generating: homepage");
let content = Content::from_path(&index_path, ContentKind::Section)?;
let html_body = render::markdown_to_html(&content.body);
let page =
templates::render_homepage(&content.frontmatter, &html_body, "/index.html", config, nav);
let html = engine.render_page(&content, &html_body, "/index.html", config, nav)?;
let out_path = output_dir.join("index.html");
@@ -258,7 +216,7 @@ fn generate_homepage(
source: e,
})?;
fs::write(&out_path, page.into_string()).map_err(|e| Error::WriteFile {
fs::write(&out_path, html).map_err(|e| Error::WriteFile {
path: out_path.clone(),
source: e,
})?;

View File

@@ -44,6 +44,7 @@ impl TemplateEngine {
nav: &[NavItem],
) -> Result<String> {
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)
@@ -64,6 +65,7 @@ impl TemplateEngine {
.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)
@@ -73,19 +75,16 @@ impl TemplateEngine {
pub fn render_section(
&self,
section: &Content,
section_type: &str,
items: &[ContentContext],
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
) -> Result<String> {
let section_type = section
.frontmatter
.section_type
.as_deref()
.unwrap_or("default");
let template = format!("section/{}.html", section_type);
let mut ctx = self.base_context(page_path, config, nav);
ctx.insert("title", &section.frontmatter.title);
ctx.insert("section", &FrontmatterContext::from(&section.frontmatter));
ctx.insert("items", items);
self.render(&template, &ctx)
@@ -98,6 +97,8 @@ impl TemplateEngine {
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
}
}

View File

@@ -1,247 +0,0 @@
//! HTML templates using maud.
use crate::config::SiteConfig;
use crate::content::{Content, Frontmatter, NavItem};
use maud::{html, Markup, PreEscaped, DOCTYPE};
/// Compute relative path prefix based on page depth.
/// depth=0 (root) → "."
/// depth=1 (one level deep) → ".."
/// depth=2 → "../.."
fn relative_prefix(depth: usize) -> String {
if depth == 0 {
".".to_string()
} else {
(0..depth).map(|_| "..").collect::<Vec<_>>().join("/")
}
}
/// Calculate directory depth from page path.
/// "/index.html" → 0
/// "/about.html" → 0
/// "/blog/index.html" → 1
/// "/blog/slug.html" → 1
fn path_depth(page_path: &str) -> usize {
// Count segments: split by '/', filter empties, subtract 1 for filename
let segments: Vec<_> = page_path.split('/').filter(|s| !s.is_empty()).collect();
segments.len().saturating_sub(1)
}
/// Render a blog post with the base layout.
pub fn render_post(
frontmatter: &Frontmatter,
content_html: &str,
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
) -> Markup {
let depth = path_depth(page_path);
let prefix = relative_prefix(depth);
base_layout(
&frontmatter.title,
html! {
article.post {
header {
h1 { (frontmatter.title) }
@if let Some(ref date) = frontmatter.date {
time.date { (date) }
}
@if let Some(ref desc) = frontmatter.description {
p.description { (desc) }
}
@if !frontmatter.tags.is_empty() {
ul.tags {
@for tag in &frontmatter.tags {
li { a href=(format!("{}/tags/{}.html", prefix, tag)) { (tag) } }
}
}
}
}
section.content {
(PreEscaped(content_html))
}
}
},
page_path,
config,
nav,
)
}
/// Render a standalone page (about, collab, etc.)
pub fn render_page(
frontmatter: &Frontmatter,
content_html: &str,
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
) -> Markup {
base_layout(
&frontmatter.title,
html! {
article.page {
h1 { (frontmatter.title) }
section.content {
(PreEscaped(content_html))
}
}
},
page_path,
config,
nav,
)
}
/// Render the homepage.
pub fn render_homepage(
frontmatter: &Frontmatter,
content_html: &str,
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
) -> Markup {
base_layout(
&frontmatter.title,
html! {
section.hero {
h1 { (frontmatter.title) }
@if let Some(ref desc) = frontmatter.description {
p.tagline { (desc) }
}
}
section.content {
(PreEscaped(content_html))
}
},
page_path,
config,
nav,
)
}
/// Render the blog listing page.
pub fn render_blog_index(
title: &str,
posts: &[Content],
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
) -> Markup {
base_layout(
title,
html! {
h1 { (title) }
ul.post-list {
@for post in posts {
li {
// Posts are .html files in the same directory
a href=(format!("./{}.html", post.slug)) {
span.title { (post.frontmatter.title) }
@if let Some(ref date) = post.frontmatter.date {
time.date { (date) }
}
}
@if let Some(ref desc) = post.frontmatter.description {
p.description { (desc) }
}
}
}
}
},
page_path,
config,
nav,
)
}
/// Render the projects page with cards.
pub fn render_projects_index(
title: &str,
projects: &[Content],
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
) -> Markup {
base_layout(
title,
html! {
h1 { (title) }
ul.project-cards {
@for project in projects {
li.card {
@if let Some(ref link) = project.frontmatter.link_to {
a href=(link) target="_blank" rel="noopener" {
h2 { (project.frontmatter.title) }
@if let Some(ref desc) = project.frontmatter.description {
p { (desc) }
}
}
} @else {
h2 { (project.frontmatter.title) }
@if let Some(ref desc) = project.frontmatter.description {
p { (desc) }
}
}
}
}
}
},
page_path,
config,
nav,
)
}
/// Base HTML layout wrapper.
fn base_layout(
title: &str,
content: Markup,
page_path: &str,
config: &SiteConfig,
nav: &[NavItem],
) -> Markup {
let depth = path_depth(page_path);
let prefix = relative_prefix(depth);
let canonical_url = format!("{}{}", config.base_url.trim_end_matches('/'), page_path);
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (title) " | " (config.title) }
link rel="canonical" href=(canonical_url);
link rel="alternate" type="application/atom+xml" title="Atom Feed" href=(format!("{}/feed.xml", config.base_url.trim_end_matches('/')));
link rel="stylesheet" href=(format!("{}/style.css", prefix));
}
body {
nav {
a href=(format!("{}/index.html", prefix)) { (config.title) }
@for item in nav {
a href=(format!("{}{}", prefix, item.path)) { (item.label) }
}
}
main {
(content)
}
footer {
p { "© " (config.author) }
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relative_prefix() {
assert_eq!(relative_prefix(0), ".");
assert_eq!(relative_prefix(1), "..");
assert_eq!(relative_prefix(2), "../..");
assert_eq!(relative_prefix(3), "../../..");
}
}

View File

@@ -1,13 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} | {{ config.title }}</title>
<link rel="canonical" href="{{ config.base_url | trim_end_matches(pat='/') }}{{ page_path }}">
<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{{ config.base_url | trim_end_matches(pat='/') }}/feed.xml">
<link rel="canonical" href="{{ base_url }}{{ page_path }}">
<link rel="alternate" type="application/atom+xml" title="Atom Feed" href="{{ base_url }}/feed.xml">
<link rel="stylesheet" href="{{ prefix }}/style.css">
</head>
<body>
<nav>
<a href="{{ prefix }}/index.html">{{ config.title }}</a>
@@ -22,4 +24,5 @@
<p>© {{ config.author }}</p>
</footer>
</body>
</html>