feat: add TOML site config for metadata

- Cargo.toml: Add toml and serde dependencies
- site.toml: New config with title, author, base_url
- src/config.rs: SiteConfig struct with load() function
- src/error.rs: Add Error::Config for parse errors
- src/main.rs: Load site.toml, thread config and page paths
  through generators
- src/templates.rs: Use config.title in nav/titles,
  config.author in footer, config.base_url for canonical URLs.

All three config fields verified in generated HTML output.
This commit is contained in:
Timothy DeHerrera
2026-01-24 21:47:47 -07:00
parent 71d5ac1e37
commit d166e86435
7 changed files with 215 additions and 30 deletions

51
src/config.rs Normal file
View File

@@ -0,0 +1,51 @@
//! Site configuration loading.
use crate::error::{Error, Result};
use serde::Deserialize;
use std::fs;
use std::path::Path;
/// Site-wide configuration loaded from site.toml.
#[derive(Debug, Deserialize)]
pub struct SiteConfig {
/// Site title (used in page titles and nav).
pub title: String,
/// Site author name.
pub author: String,
/// Base URL for the site (used for feeds, canonical links).
pub base_url: String,
}
impl SiteConfig {
/// Load configuration from a TOML file.
pub fn load(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path).map_err(|e| Error::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
toml::from_str(&content).map_err(|e| Error::Config {
path: path.to_path_buf(),
message: e.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_config() {
let toml = r#"
title = "Test Site"
author = "Test Author"
base_url = "https://example.com/"
"#;
let config: SiteConfig = toml::from_str(toml).unwrap();
assert_eq!(config.title, "Test Site");
assert_eq!(config.author, "Test Author");
assert_eq!(config.base_url, "https://example.com/");
}
}

View File

@@ -36,6 +36,10 @@ pub enum Error {
/// Content directory not found.
#[error("content directory not found: {0}")]
ContentDirNotFound(PathBuf),
/// Failed to parse configuration file.
#[error("invalid config in {path}: {message}")]
Config { path: PathBuf, message: String },
}
/// Result type alias for compiler operations.

View File

@@ -2,6 +2,7 @@
//!
//! Transforms markdown content into a minimal static site.
mod config;
mod content;
mod css;
mod error;
@@ -25,23 +26,27 @@ fn run() -> Result<()> {
let content_dir = Path::new("content");
let output_dir = Path::new("public");
let static_dir = Path::new("static");
let config_path = Path::new("site.toml");
if !content_dir.exists() {
return Err(Error::ContentDirNotFound(content_dir.to_path_buf()));
}
// Load site configuration
let config = config::SiteConfig::load(config_path)?;
// 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)?;
let mut posts = process_blog_posts(content_dir, output_dir, &config)?;
// 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)?;
generate_blog_index(output_dir, &posts, &config)?;
// 3. Process standalone pages (about, collab)
process_pages(content_dir, output_dir)?;
process_pages(content_dir, output_dir, &config)?;
// 4. Process projects and generate project index
let mut projects = process_projects(content_dir)?;
@@ -51,17 +56,21 @@ fn run() -> Result<()> {
.unwrap_or(99)
.cmp(&b.frontmatter.weight.unwrap_or(99))
});
generate_projects_index(output_dir, &projects)?;
generate_projects_index(output_dir, &projects, &config)?;
// 5. Generate homepage
generate_homepage(content_dir, output_dir)?;
generate_homepage(content_dir, output_dir, &config)?;
eprintln!("done!");
Ok(())
}
/// Process all blog posts in content/blog/
fn process_blog_posts(content_dir: &Path, output_dir: &Path) -> Result<Vec<Content>> {
fn process_blog_posts(
content_dir: &Path,
output_dir: &Path,
config: &config::SiteConfig,
) -> Result<Vec<Content>> {
let blog_dir = content_dir.join("blog");
let mut posts = Vec::new();
@@ -78,7 +87,8 @@ fn process_blog_posts(content_dir: &Path, output_dir: &Path) -> Result<Vec<Conte
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, 1);
let page_path = format!("/{}", content.output_path(content_dir).display());
let page = templates::render_post(&content.frontmatter, &html_body, &page_path, config);
write_output(output_dir, content_dir, &content, page.into_string())?;
posts.push(content);
@@ -88,11 +98,15 @@ fn process_blog_posts(content_dir: &Path, output_dir: &Path) -> Result<Vec<Conte
}
/// Generate the blog listing page
fn generate_blog_index(output_dir: &Path, posts: &[Content]) -> Result<()> {
fn generate_blog_index(
output_dir: &Path,
posts: &[Content],
config: &config::SiteConfig,
) -> Result<()> {
let out_path = output_dir.join("blog/index.html");
eprintln!("generating: {}", out_path.display());
let page = templates::render_blog_index("Blog", posts, 1);
let page = templates::render_blog_index("Blog", posts, "/blog/index.html", config);
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
path: out_path.parent().unwrap().to_path_buf(),
@@ -109,7 +123,7 @@ fn generate_blog_index(output_dir: &Path, posts: &[Content]) -> Result<()> {
}
/// Process standalone pages in content/ (about.md, collab.md)
fn process_pages(content_dir: &Path, output_dir: &Path) -> Result<()> {
fn process_pages(content_dir: &Path, output_dir: &Path, config: &config::SiteConfig) -> Result<()> {
for name in ["about.md", "collab.md"] {
let path = content_dir.join(name);
if path.exists() {
@@ -117,7 +131,8 @@ fn process_pages(content_dir: &Path, output_dir: &Path) -> Result<()> {
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, 0);
let page_path = format!("/{}", content.output_path(content_dir).display());
let page = templates::render_page(&content.frontmatter, &html_body, &page_path, config);
write_output(output_dir, content_dir, &content, page.into_string())?;
}
@@ -146,11 +161,16 @@ fn process_projects(content_dir: &Path) -> Result<Vec<Content>> {
}
/// Generate the projects listing page
fn generate_projects_index(output_dir: &Path, projects: &[Content]) -> Result<()> {
fn generate_projects_index(
output_dir: &Path,
projects: &[Content],
config: &config::SiteConfig,
) -> Result<()> {
let out_path = output_dir.join("projects/index.html");
eprintln!("generating: {}", out_path.display());
let page = templates::render_projects_index("Projects", projects, 1);
let page =
templates::render_projects_index("Projects", projects, "/projects/index.html", config);
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
path: out_path.parent().unwrap().to_path_buf(),
@@ -167,13 +187,17 @@ fn generate_projects_index(output_dir: &Path, projects: &[Content]) -> Result<()
}
/// Generate the homepage from content/_index.md
fn generate_homepage(content_dir: &Path, output_dir: &Path) -> Result<()> {
fn generate_homepage(
content_dir: &Path,
output_dir: &Path,
config: &config::SiteConfig,
) -> 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, 0);
let page = templates::render_homepage(&content.frontmatter, &html_body, "/index.html", config);
let out_path = output_dir.join("index.html");

View File

@@ -1,5 +1,6 @@
//! HTML templates using maud.
use crate::config::SiteConfig;
use crate::content::{Content, Frontmatter};
use maud::{html, Markup, PreEscaped, DOCTYPE};
@@ -16,7 +17,13 @@ fn relative_prefix(depth: usize) -> String {
}
/// Render a blog post with the base layout.
pub fn render_post(frontmatter: &Frontmatter, content_html: &str, depth: usize) -> Markup {
pub fn render_post(
frontmatter: &Frontmatter,
content_html: &str,
page_path: &str,
config: &SiteConfig,
) -> Markup {
let depth = page_path.matches('/').count();
let prefix = relative_prefix(depth);
base_layout(
&frontmatter.title,
@@ -43,12 +50,18 @@ pub fn render_post(frontmatter: &Frontmatter, content_html: &str, depth: usize)
}
}
},
depth,
page_path,
config,
)
}
/// Render a standalone page (about, collab, etc.)
pub fn render_page(frontmatter: &Frontmatter, content_html: &str, depth: usize) -> Markup {
pub fn render_page(
frontmatter: &Frontmatter,
content_html: &str,
page_path: &str,
config: &SiteConfig,
) -> Markup {
base_layout(
&frontmatter.title,
html! {
@@ -59,12 +72,18 @@ pub fn render_page(frontmatter: &Frontmatter, content_html: &str, depth: usize)
}
}
},
depth,
page_path,
config,
)
}
/// Render the homepage.
pub fn render_homepage(frontmatter: &Frontmatter, content_html: &str, depth: usize) -> Markup {
pub fn render_homepage(
frontmatter: &Frontmatter,
content_html: &str,
page_path: &str,
config: &SiteConfig,
) -> Markup {
base_layout(
&frontmatter.title,
html! {
@@ -78,12 +97,18 @@ pub fn render_homepage(frontmatter: &Frontmatter, content_html: &str, depth: usi
(PreEscaped(content_html))
}
},
depth,
page_path,
config,
)
}
/// Render the blog listing page.
pub fn render_blog_index(title: &str, posts: &[Content], depth: usize) -> Markup {
pub fn render_blog_index(
title: &str,
posts: &[Content],
page_path: &str,
config: &SiteConfig,
) -> Markup {
base_layout(
title,
html! {
@@ -105,12 +130,18 @@ pub fn render_blog_index(title: &str, posts: &[Content], depth: usize) -> Markup
}
}
},
depth,
page_path,
config,
)
}
/// Render the projects page with cards.
pub fn render_projects_index(title: &str, projects: &[Content], depth: usize) -> Markup {
pub fn render_projects_index(
title: &str,
projects: &[Content],
page_path: &str,
config: &SiteConfig,
) -> Markup {
base_layout(
title,
html! {
@@ -135,25 +166,30 @@ pub fn render_projects_index(title: &str, projects: &[Content], depth: usize) ->
}
}
},
depth,
page_path,
config,
)
}
/// Base HTML layout wrapper.
fn base_layout(title: &str, content: Markup, depth: usize) -> Markup {
fn base_layout(title: &str, content: Markup, page_path: &str, config: &SiteConfig) -> Markup {
let depth = page_path.matches('/').count();
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) " | nrd.sh" }
title { (title) " | " (config.title) }
link rel="canonical" href=(canonical_url);
link rel="stylesheet" href=(format!("{}/style.css", prefix));
}
body {
nav {
a href=(format!("{}/index.html", prefix)) { "nrd.sh" }
a href=(format!("{}/index.html", prefix)) { (config.title) }
a href=(format!("{}/blog/index.html", prefix)) { "blog" }
a href=(format!("{}/projects/index.html", prefix)) { "projects" }
a href=(format!("{}/about.html", prefix)) { "about" }
@@ -162,7 +198,7 @@ fn base_layout(title: &str, content: Markup, depth: usize) -> Markup {
(content)
}
footer {
p { "© nrdxp" }
p { "© " (config.author) }
}
}
}