feat: implement cohesive site structure

Add content type handling and multi-page generation:

- content.rs: Add ContentKind enum (Post, Page, Section, Project)
  and extend Frontmatter with weight/link_to fields
- templates.rs: Add render_page, render_homepage,
  render_blog_index, render_projects_index templates
- main.rs: Process all content types with dedicated handlers

Output structure:
- /index.html (homepage from _index.md)
- /blog/index.html (post listing, sorted by date)
- /blog/<slug>/index.html (individual posts)
- /about/index.html, /collab/index.html (standalone pages)
- /projects/index.html (project cards with external links)
This commit is contained in:
Timothy DeHerrera
2026-01-24 19:19:53 -07:00
parent 0cee5325d3
commit 06b7e0df64
3 changed files with 296 additions and 29 deletions

View File

@@ -1,10 +1,23 @@
//! Content discovery and frontmatter parsing. //! Content discovery and frontmatter parsing.
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use gray_matter::{engine::YAML, Matter}; use gray_matter::{Matter, engine::YAML};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; 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. /// Parsed frontmatter from a content file.
#[derive(Debug)] #[derive(Debug)]
pub struct Frontmatter { pub struct Frontmatter {
@@ -12,11 +25,16 @@ pub struct Frontmatter {
pub description: Option<String>, pub description: Option<String>,
pub date: Option<String>, pub date: Option<String>,
pub tags: Vec<String>, pub tags: Vec<String>,
/// For project cards: sort order
pub weight: Option<i64>,
/// For project cards: external link
pub link_to: Option<String>,
} }
/// A content item ready for rendering. /// A content item ready for rendering.
#[derive(Debug)] #[derive(Debug)]
pub struct Content { pub struct Content {
pub kind: ContentKind,
pub frontmatter: Frontmatter, pub frontmatter: Frontmatter,
pub body: String, pub body: String,
pub source_path: PathBuf, pub source_path: PathBuf,
@@ -24,9 +42,12 @@ pub struct Content {
} }
impl Content { impl Content {
/// Load and parse a markdown file with TOML frontmatter. /// Load and parse a markdown file with YAML frontmatter.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> { pub fn from_path(path: impl AsRef<Path>, kind: ContentKind) -> Result<Self> {
let path = path.as_ref(); Self::from_path_inner(path.as_ref(), kind)
}
fn from_path_inner(path: &Path, kind: ContentKind) -> Result<Self> {
let raw = fs::read_to_string(path).map_err(|e| Error::ReadFile { let raw = fs::read_to_string(path).map_err(|e| Error::ReadFile {
path: path.to_path_buf(), path: path.to_path_buf(),
source: e, source: e,
@@ -45,6 +66,7 @@ impl Content {
.to_string(); .to_string();
Ok(Content { Ok(Content {
kind,
frontmatter, frontmatter,
body: parsed.content, body: parsed.content,
source_path: path.to_path_buf(), source_path: path.to_path_buf(),
@@ -60,8 +82,18 @@ impl Content {
.strip_prefix(content_root) .strip_prefix(content_root)
.unwrap_or(&self.source_path); .unwrap_or(&self.source_path);
let parent = relative.parent().unwrap_or(Path::new("")); match self.kind {
parent.join(&self.slug).join("index.html") 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 description = pod.get("description").and_then(|v| v.as_string().ok());
let date = pod.get("date").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 // Handle nested taxonomies.tags structure
let tags = if let Some(taxonomies) = pod.get("taxonomies") { let tags = if let Some(taxonomies) = pod.get("taxonomies") {
@@ -111,5 +145,7 @@ fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result<
description, description,
date, date,
tags, tags,
weight,
link_to,
}) })
} }

View File

@@ -7,7 +7,7 @@ mod error;
mod render; mod render;
mod templates; mod templates;
use crate::content::Content; use crate::content::{Content, ContentKind};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@@ -27,40 +27,184 @@ fn run() -> Result<()> {
return Err(Error::ContentDirNotFound(content_dir.to_path_buf())); 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<Vec<Content>> {
let blog_dir = content_dir.join("blog"); let blog_dir = content_dir.join("blog");
let mut posts = Vec::new();
for entry in walkdir::WalkDir::new(&blog_dir) for entry in walkdir::WalkDir::new(&blog_dir)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .filter(|e| {
e.path().extension().map_or(false, |ext| ext == "md") e.path().extension().is_some_and(|ext| ext == "md")
&& e.path().file_name().map_or(false, |n| n != "_index.md") && e.path().file_name().is_some_and(|n| n != "_index.md")
}) })
{ {
let path = entry.path(); let path = entry.path();
eprintln!("processing: {}", path.display()); 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 html_body = render::markdown_to_html(&content.body);
let page = templates::render_post(&content.frontmatter, &html_body); let page = templates::render_post(&content.frontmatter, &html_body);
let out_path = output_dir.join(content.output_path(content_dir)); write_output(output_dir, content_dir, &content, page.into_string())?;
let out_dir = out_path.parent().unwrap(); posts.push(content);
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());
} }
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<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]) -> 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(()) Ok(())
} }

View File

@@ -1,7 +1,7 @@
//! HTML templates using maud. //! HTML templates using maud.
use crate::content::Frontmatter; use crate::content::{Content, Frontmatter};
use maud::{html, Markup, DOCTYPE}; use maud::{DOCTYPE, Markup, PreEscaped, html};
/// Render a blog post with the base layout. /// Render a blog post with the base layout.
pub fn render_post(frontmatter: &Frontmatter, content_html: &str) -> Markup { 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 { 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) }
}
}
}
} }
} }
}, },