diff --git a/Cargo.lock b/Cargo.lock index 6c23ac8..74b37eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index abee6d6..bc1c998 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ version = "0.1.0" [dependencies] gray_matter = "0.2" -maud = "0.26" pulldown-cmark = "0.12" thiserror = "2" walkdir = "2" diff --git a/src/content.rs b/src/content.rs index 17f1537..8932b8a 100644 --- a/src/content.rs +++ b/src/content.rs @@ -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, @@ -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, diff --git a/src/main.rs b/src/main.rs index 6d4e265..65431a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 §ions { + 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)?; + // 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)) + }); + } + _ => { + // 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)) + }); + } + } - // 4. Process projects and generate project index - let mut projects = process_projects(content_dir)?; - projects.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)?; + // 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)?; + } + } - // 5. Generate homepage - generate_homepage(content_dir, output_dir, &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( + §ion.index, + §ion.section_type, + &item_contexts, + &page_path, + &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> { - 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); + let out_path = output_dir.join(§ion.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, html).map_err(|e| Error::WriteFile { + path: out_path.clone(), + source: e, + })?; + eprintln!(" → {}", out_path.display()); } - Ok(posts) -} + // 2. Generate Atom feed (blog posts only) + if !all_posts.is_empty() { + generate_feed(output_dir, &all_posts, &config, content_dir)?; + } -/// 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()); + // 3. Process standalone pages (discovered dynamically) + process_pages(content_dir, output_dir, &config, &nav, &engine)?; - let page = templates::render_blog_index("Blog", posts, "/blog/index.html", config, nav); + // 4. Generate homepage + generate_homepage(content_dir, output_dir, &config, &nav, &engine)?; - 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()); + 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> { - 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, })?; diff --git a/src/template_engine.rs b/src/template_engine.rs index 8301f5c..2e9df81 100644 --- a/src/template_engine.rs +++ b/src/template_engine.rs @@ -44,6 +44,7 @@ impl TemplateEngine { 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) @@ -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 { - 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", §ion.frontmatter.title); ctx.insert("section", &FrontmatterContext::from(§ion.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 } } diff --git a/src/templates.rs b/src/templates.rs deleted file mode 100644 index 77e3206..0000000 --- a/src/templates.rs +++ /dev/null @@ -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::>().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), "../../.."); - } -} diff --git a/templates/base.html b/templates/base.html index 490c521..f1a70b8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,13 +1,15 @@ + {{ title }} | {{ config.title }} - - + + +