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:
@@ -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,10 +82,20 @@ impl Content {
|
|||||||
.strip_prefix(content_root)
|
.strip_prefix(content_root)
|
||||||
.unwrap_or(&self.source_path);
|
.unwrap_or(&self.source_path);
|
||||||
|
|
||||||
|
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(""));
|
let parent = relative.parent().unwrap_or(Path::new(""));
|
||||||
parent.join(&self.slug).join("index.html")
|
parent.join(&self.slug).join("index.html")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result<Frontmatter> {
|
fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result<Frontmatter> {
|
||||||
let data = parsed.data.as_ref().ok_or_else(|| Error::Frontmatter {
|
let data = parsed.data.as_ref().ok_or_else(|| Error::Frontmatter {
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
168
src/main.rs
168
src/main.rs
@@ -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,29 +27,69 @@ 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 {
|
Ok(posts)
|
||||||
path: out_dir.to_path_buf(),
|
}
|
||||||
|
|
||||||
|
/// 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,
|
source: e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -59,8 +99,112 @@ fn run() -> Result<()> {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
eprintln!(" → {}", out_path.display());
|
eprintln!(" → {}", out_path.display());
|
||||||
}
|
Ok(())
|
||||||
|
}
|
||||||
eprintln!("done!");
|
|
||||||
|
/// 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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user