From d166e86435cfe7fb95940cf61ac302eecc6da56c Mon Sep 17 00:00:00 2001 From: Timothy DeHerrera Date: Sat, 24 Jan 2026 21:47:47 -0700 Subject: [PATCH] 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. --- Cargo.lock | 63 ++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 +++ site.toml | 5 ++++ src/config.rs | 51 ++++++++++++++++++++++++++++++++++++++ src/error.rs | 4 +++ src/main.rs | 54 ++++++++++++++++++++++++++++------------ src/templates.rs | 64 +++++++++++++++++++++++++++++++++++++----------- 7 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 site.toml create mode 100644 src/config.rs diff --git a/Cargo.lock b/Cargo.lock index ccdcc60..4c4b862 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,7 +326,7 @@ checksum = "8666976c40b8633f918783969b6681a3ddb205f29150348617de425d85a3e3bd" dependencies = [ "serde", "serde_json", - "toml", + "toml 0.5.11", "yaml-rust2", ] @@ -510,7 +510,9 @@ dependencies = [ "lightningcss", "maud", "pulldown-cmark", + "serde", "thiserror 2.0.18", + "toml 0.8.23", "tree-sitter-bash", "tree-sitter-highlight", "tree-sitter-json", @@ -931,6 +933,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1062,6 +1073,47 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tree-sitter" version = "0.24.7" @@ -1264,6 +1316,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 1aefb28..f361a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,7 @@ tree-sitter-rust = "0.23" # CSS processing lightningcss = "1.0.0-alpha.70" + +# Config parsing +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/site.toml b/site.toml new file mode 100644 index 0000000..30334af --- /dev/null +++ b/site.toml @@ -0,0 +1,5 @@ +# Site configuration for nrd.sh static site compiler + +author = "nrdxp" +base_url = "https://nrd.sh/" +title = "nrd.sh" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..41a90ff --- /dev/null +++ b/src/config.rs @@ -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 { + 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/"); + } +} diff --git a/src/error.rs b/src/error.rs index d51d291..02ee233 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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. diff --git a/src/main.rs b/src/main.rs index e1aa34e..d8072b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { +fn process_blog_posts( + content_dir: &Path, + output_dir: &Path, + config: &config::SiteConfig, +) -> Result> { 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 Result 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> { } /// 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"); diff --git a/src/templates.rs b/src/templates.rs index dc6c6d0..3d52364 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -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) } } } }