diff --git a/src/content.rs b/src/content.rs index 4bf212a..046f607 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,10 +1,23 @@ //! Content discovery and frontmatter parsing. use crate::error::{Error, Result}; -use gray_matter::{engine::YAML, Matter}; +use gray_matter::{Matter, engine::YAML}; use std::fs; use std::path::{Path, PathBuf}; +/// The type of content being processed. +#[derive(Debug, Clone, PartialEq)] +pub enum ContentKind { + /// Blog post with full metadata (date, tags, etc.) + Post, + /// Standalone page (about, collab) + Page, + /// Section index (_index.md) + Section, + /// Project card with external link + Project, +} + /// Parsed frontmatter from a content file. #[derive(Debug)] pub struct Frontmatter { @@ -12,11 +25,16 @@ pub struct Frontmatter { pub description: Option, pub date: Option, pub tags: Vec, + /// For project cards: sort order + pub weight: Option, + /// For project cards: external link + pub link_to: Option, } /// A content item ready for rendering. #[derive(Debug)] pub struct Content { + pub kind: ContentKind, pub frontmatter: Frontmatter, pub body: String, pub source_path: PathBuf, @@ -24,9 +42,12 @@ pub struct Content { } impl Content { - /// Load and parse a markdown file with TOML frontmatter. - pub fn from_path(path: impl AsRef) -> Result { - let path = path.as_ref(); + /// Load and parse a markdown file with YAML frontmatter. + pub fn from_path(path: impl AsRef, kind: ContentKind) -> Result { + Self::from_path_inner(path.as_ref(), kind) + } + + fn from_path_inner(path: &Path, kind: ContentKind) -> Result { let raw = fs::read_to_string(path).map_err(|e| Error::ReadFile { path: path.to_path_buf(), source: e, @@ -45,6 +66,7 @@ impl Content { .to_string(); Ok(Content { + kind, frontmatter, body: parsed.content, source_path: path.to_path_buf(), @@ -60,8 +82,18 @@ impl Content { .strip_prefix(content_root) .unwrap_or(&self.source_path); - let parent = relative.parent().unwrap_or(Path::new("")); - parent.join(&self.slug).join("index.html") + match self.kind { + ContentKind::Section => { + // _index.md → parent/index.html + let parent = relative.parent().unwrap_or(Path::new("")); + parent.join("index.html") + } + _ => { + // Regular content → parent/slug/index.html + let parent = relative.parent().unwrap_or(Path::new("")); + parent.join(&self.slug).join("index.html") + } + } } } @@ -86,6 +118,8 @@ fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result< let description = pod.get("description").and_then(|v| v.as_string().ok()); let date = pod.get("date").and_then(|v| v.as_string().ok()); + let weight = pod.get("weight").and_then(|v| v.as_i64().ok()); + let link_to = pod.get("link_to").and_then(|v| v.as_string().ok()); // Handle nested taxonomies.tags structure let tags = if let Some(taxonomies) = pod.get("taxonomies") { @@ -111,5 +145,7 @@ fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result< description, date, tags, + weight, + link_to, }) } diff --git a/src/main.rs b/src/main.rs index 0b8515d..ba3ccae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ mod error; mod render; mod templates; -use crate::content::Content; +use crate::content::{Content, ContentKind}; use crate::error::{Error, Result}; use std::fs; use std::path::Path; @@ -27,40 +27,184 @@ fn run() -> Result<()> { return Err(Error::ContentDirNotFound(content_dir.to_path_buf())); } - // For MVP: process all markdown files in content/blog/ + // 1. Process blog posts + let mut posts = process_blog_posts(content_dir, output_dir)?; + + // 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)?; + + // 3. Process standalone pages (about, collab) + process_pages(content_dir, output_dir)?; + + // 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)?; + + // 5. Generate homepage + generate_homepage(content_dir, output_dir)?; + + eprintln!("done!"); + Ok(()) +} + +/// Process all blog posts in content/blog/ +fn process_blog_posts(content_dir: &Path, output_dir: &Path) -> 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().map_or(false, |ext| ext == "md") - && e.path().file_name().map_or(false, |n| n != "_index.md") + 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)?; + let content = Content::from_path(path, ContentKind::Post)?; let html_body = render::markdown_to_html(&content.body); let page = templates::render_post(&content.frontmatter, &html_body); - let out_path = output_dir.join(content.output_path(content_dir)); - let out_dir = out_path.parent().unwrap(); - - fs::create_dir_all(out_dir).map_err(|e| Error::CreateDir { - path: out_dir.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()); + write_output(output_dir, content_dir, &content, page.into_string())?; + posts.push(content); } - eprintln!("done!"); + Ok(posts) +} + +/// Generate the blog listing page +fn generate_blog_index(output_dir: &Path, posts: &[Content]) -> Result<()> { + let out_path = output_dir.join("blog/index.html"); + eprintln!("generating: {}", out_path.display()); + + let page = templates::render_blog_index("Blog", posts); + + 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(()) +} + +/// Process standalone pages in content/ (about.md, collab.md) +fn process_pages(content_dir: &Path, output_dir: &Path) -> Result<()> { + for name in ["about.md", "collab.md"] { + let path = content_dir.join(name); + if path.exists() { + eprintln!("processing: {}", path.display()); + + let content = Content::from_path(&path, ContentKind::Page)?; + let html_body = render::markdown_to_html(&content.body); + let page = templates::render_page(&content.frontmatter, &html_body); + + write_output(output_dir, content_dir, &content, page.into_string())?; + } + } + 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]) -> Result<()> { + let out_path = output_dir.join("projects/index.html"); + eprintln!("generating: {}", out_path.display()); + + let page = templates::render_projects_index("Projects", projects); + + 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) -> 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); + + let out_path = output_dir.join("index.html"); + + fs::create_dir_all(output_dir).map_err(|e| Error::CreateDir { + path: output_dir.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(()) +} + +/// Write a content item to its output path +fn write_output( + output_dir: &Path, + content_dir: &Path, + content: &Content, + html: String, +) -> Result<()> { + let out_path = output_dir.join(content.output_path(content_dir)); + let out_dir = out_path.parent().unwrap(); + + fs::create_dir_all(out_dir).map_err(|e| Error::CreateDir { + path: out_dir.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(()) } diff --git a/src/templates.rs b/src/templates.rs index 62ccbad..b552669 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,7 +1,7 @@ //! HTML templates using maud. -use crate::content::Frontmatter; -use maud::{html, Markup, DOCTYPE}; +use crate::content::{Content, Frontmatter}; +use maud::{DOCTYPE, Markup, PreEscaped, html}; /// Render a blog post with the base layout. pub fn render_post(frontmatter: &Frontmatter, content_html: &str) -> Markup { @@ -26,7 +26,94 @@ pub fn render_post(frontmatter: &Frontmatter, content_html: &str) -> Markup { } } section.content { - (maud::PreEscaped(content_html)) + (PreEscaped(content_html)) + } + } + }, + ) +} + +/// Render a standalone page (about, collab, etc.) +pub fn render_page(frontmatter: &Frontmatter, content_html: &str) -> Markup { + base_layout( + &frontmatter.title, + html! { + article.page { + h1 { (frontmatter.title) } + section.content { + (PreEscaped(content_html)) + } + } + }, + ) +} + +/// Render the homepage. +pub fn render_homepage(frontmatter: &Frontmatter, content_html: &str) -> 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)) + } + }, + ) +} + +/// Render the blog listing page. +pub fn render_blog_index(title: &str, posts: &[Content]) -> Markup { + base_layout( + title, + html! { + h1 { (title) } + ul.post-list { + @for post in posts { + li { + a href=(format!("/blog/{}/", 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) } + } + } + } + } + }, + ) +} + +/// Render the projects page with cards. +pub fn render_projects_index(title: &str, projects: &[Content]) -> 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) } + } + } + } } } },