diff --git a/.gitignore b/.gitignore index 8e3f1b2..b09fe79 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,8 @@ target # Local Netlify folder .netlify + .direnv target/ +.repomap* +public/ diff --git a/AGENTS.md b/AGENTS.md index c259617..da154e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ The agent MUST read and adhere to the global engineering ruleset and any active **Active Fragments:** - Rust idioms (`.agent/predicates/fragments/rust.md`) +- DepMap MCP tools (`.agent/predicates/fragments/depmap.md`) **Available Workflows:** diff --git a/Cargo.lock b/Cargo.lock index db58ef4..b255a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,8 @@ dependencies = [ "gray_matter", "maud", "pulldown-cmark", + "thiserror", + "walkdir", ] [[package]] @@ -196,6 +198,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.228" @@ -250,6 +261,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.5.11" @@ -283,6 +314,40 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "yaml-rust2" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 886431b..555c4c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,5 @@ version = "0.1.0" gray_matter = "0.2" maud = "0.26" pulldown-cmark = "0.12" +thiserror = "2" +walkdir = "2" diff --git a/src/content.rs b/src/content.rs new file mode 100644 index 0000000..4bf212a --- /dev/null +++ b/src/content.rs @@ -0,0 +1,115 @@ +//! Content discovery and frontmatter parsing. + +use crate::error::{Error, Result}; +use gray_matter::{engine::YAML, Matter}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Parsed frontmatter from a content file. +#[derive(Debug)] +pub struct Frontmatter { + pub title: String, + pub description: Option, + pub date: Option, + pub tags: Vec, +} + +/// A content item ready for rendering. +#[derive(Debug)] +pub struct Content { + pub frontmatter: Frontmatter, + pub body: String, + pub source_path: PathBuf, + pub slug: String, +} + +impl Content { + /// Load and parse a markdown file with TOML frontmatter. + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let raw = fs::read_to_string(path).map_err(|e| Error::ReadFile { + path: path.to_path_buf(), + source: e, + })?; + + let matter = Matter::::new(); + let parsed = matter.parse(&raw); + + let frontmatter = parse_frontmatter(path, &parsed)?; + + // Derive slug from filename (without extension) + let slug = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled") + .to_string(); + + Ok(Content { + frontmatter, + body: parsed.content, + source_path: path.to_path_buf(), + slug, + }) + } + + /// Compute the output path relative to the output directory. + /// e.g., content/blog/foo.md → blog/foo/index.html + pub fn output_path(&self, content_root: &Path) -> PathBuf { + let relative = self + .source_path + .strip_prefix(content_root) + .unwrap_or(&self.source_path); + + let parent = relative.parent().unwrap_or(Path::new("")); + parent.join(&self.slug).join("index.html") + } +} + +fn parse_frontmatter(path: &Path, parsed: &gray_matter::ParsedEntity) -> Result { + let data = parsed.data.as_ref().ok_or_else(|| Error::Frontmatter { + path: path.to_path_buf(), + message: "missing frontmatter".to_string(), + })?; + + let pod = data.as_hashmap().map_err(|_| Error::Frontmatter { + path: path.to_path_buf(), + message: "frontmatter is not a valid map".to_string(), + })?; + + let title = pod + .get("title") + .and_then(|v| v.as_string().ok()) + .ok_or_else(|| Error::Frontmatter { + path: path.to_path_buf(), + message: "missing required 'title' field".to_string(), + })?; + + let description = pod.get("description").and_then(|v| v.as_string().ok()); + let date = pod.get("date").and_then(|v| v.as_string().ok()); + + // Handle nested taxonomies.tags structure + let tags = if let Some(taxonomies) = pod.get("taxonomies") { + if let Ok(tax_map) = taxonomies.as_hashmap() { + if let Some(tags_pod) = tax_map.get("tags") { + if let Ok(tags_vec) = tags_pod.as_vec() { + tags_vec.iter().filter_map(|v| v.as_string().ok()).collect() + } else { + Vec::new() + } + } else { + Vec::new() + } + } else { + Vec::new() + } + } else { + Vec::new() + }; + + Ok(Frontmatter { + title, + description, + date, + tags, + }) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d51d291 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,42 @@ +//! Custom error types for the nrd.sh compiler. + +use std::path::PathBuf; + +/// All errors that can occur during site compilation. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failed to read a content file. + #[error("failed to read {path}: {source}")] + ReadFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Failed to parse frontmatter. + #[error("invalid frontmatter in {path}: {message}")] + Frontmatter { path: PathBuf, message: String }, + + /// Failed to write output file. + #[error("failed to write {path}: {source}")] + WriteFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Failed to create output directory. + #[error("failed to create directory {path}: {source}")] + CreateDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Content directory not found. + #[error("content directory not found: {0}")] + ContentDirNotFound(PathBuf), +} + +/// Result type alias for compiler operations. +pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs index d505fb5..0b8515d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,66 @@ +//! nrd.sh - Bespoke static site compiler. +//! +//! Transforms markdown content into a minimal static site. + +mod content; +mod error; +mod render; +mod templates; + +use crate::content::Content; +use crate::error::{Error, Result}; +use std::fs; +use std::path::Path; + fn main() { - println!("nrd.sh compiler v{}", env!("CARGO_PKG_VERSION")); + if let Err(e) = run() { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let content_dir = Path::new("content"); + let output_dir = Path::new("public"); + + if !content_dir.exists() { + return Err(Error::ContentDirNotFound(content_dir.to_path_buf())); + } + + // For MVP: process all markdown files in content/blog/ + let blog_dir = content_dir.join("blog"); + + 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") + }) + { + let path = entry.path(); + eprintln!("processing: {}", path.display()); + + let content = Content::from_path(path)?; + 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()); + } + + eprintln!("done!"); + Ok(()) } diff --git a/src/render.rs b/src/render.rs new file mode 100644 index 0000000..2469d77 --- /dev/null +++ b/src/render.rs @@ -0,0 +1,32 @@ +//! Markdown to HTML rendering via pulldown-cmark. + +use pulldown_cmark::{html, Options, Parser}; + +/// Render markdown content to HTML. +/// +/// Currently performs basic rendering. Future phases will intercept +/// code blocks for Tree-sitter highlighting. +pub fn markdown_to_html(markdown: &str) -> String { + let options = Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + + let parser = Parser::new_ext(markdown, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + html_output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_markdown() { + let md = "# Hello\n\nThis is a *test*."; + let html = markdown_to_html(md); + assert!(html.contains("

Hello

")); + assert!(html.contains("test")); + } +} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..62ccbad --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,63 @@ +//! HTML templates using maud. + +use crate::content::Frontmatter; +use maud::{html, Markup, DOCTYPE}; + +/// Render a blog post with the base layout. +pub fn render_post(frontmatter: &Frontmatter, content_html: &str) -> Markup { + base_layout( + &frontmatter.title, + html! { + article.post { + header { + h1 { (frontmatter.title) } + @if let Some(ref date) = frontmatter.date { + time.date { (date) } + } + @if let Some(ref desc) = frontmatter.description { + p.description { (desc) } + } + @if !frontmatter.tags.is_empty() { + ul.tags { + @for tag in &frontmatter.tags { + li { a href=(format!("/tags/{}/", tag)) { (tag) } } + } + } + } + } + section.content { + (maud::PreEscaped(content_html)) + } + } + }, + ) +} + +/// Base HTML layout wrapper. +fn base_layout(title: &str, content: Markup) -> Markup { + html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { (title) " | nrd.sh" } + link rel="stylesheet" href="/style.css"; + } + body { + nav { + a href="/" { "nrd.sh" } + a href="/blog/" { "blog" } + a href="/projects/" { "projects" } + a href="/about/" { "about" } + } + main { + (content) + } + footer { + p { "© nrdxp" } + } + } + } + } +}