feat: implement core markdown rendering pipeline

Add minimal e2e pipeline to transform content → HTML:

- src/error.rs: Custom error types with thiserror
- src/content.rs: YAML frontmatter parsing via gray_matter
- src/render.rs: Markdown → HTML via pulldown-cmark
- src/templates.rs: Maud templates for base layout and posts
- src/main.rs: Pipeline orchestrator

All 15 blog posts successfully rendered to public/blog/*/index.html.
Added thiserror and walkdir dependencies.
Added public/ and DepMap fragment to .gitignore and AGENTS.md.
This commit is contained in:
Timothy DeHerrera
2026-01-24 19:01:30 -07:00
parent 8df37127a1
commit e07a9e87e6
9 changed files with 387 additions and 1 deletions

3
.gitignore vendored
View File

@@ -3,5 +3,8 @@ target
# Local Netlify folder # Local Netlify folder
.netlify .netlify
.direnv .direnv
target/ target/
.repomap*
public/

View File

@@ -21,6 +21,7 @@ The agent MUST read and adhere to the global engineering ruleset and any active
**Active Fragments:** **Active Fragments:**
- Rust idioms (`.agent/predicates/fragments/rust.md`) - Rust idioms (`.agent/predicates/fragments/rust.md`)
- DepMap MCP tools (`.agent/predicates/fragments/depmap.md`)
**Available Workflows:** **Available Workflows:**

65
Cargo.lock generated
View File

@@ -128,6 +128,8 @@ dependencies = [
"gray_matter", "gray_matter",
"maud", "maud",
"pulldown-cmark", "pulldown-cmark",
"thiserror",
"walkdir",
] ]
[[package]] [[package]]
@@ -196,6 +198,15 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -250,6 +261,26 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "toml" name = "toml"
version = "0.5.11" version = "0.5.11"
@@ -283,6 +314,40 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "yaml-rust2" name = "yaml-rust2"
version = "0.8.1" version = "0.8.1"

View File

@@ -9,3 +9,5 @@ version = "0.1.0"
gray_matter = "0.2" gray_matter = "0.2"
maud = "0.26" maud = "0.26"
pulldown-cmark = "0.12" pulldown-cmark = "0.12"
thiserror = "2"
walkdir = "2"

115
src/content.rs Normal file
View File

@@ -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<String>,
pub date: Option<String>,
pub tags: Vec<String>,
}
/// 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<Path>) -> Result<Self> {
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::<YAML>::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<Frontmatter> {
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,
})
}

42
src/error.rs Normal file
View File

@@ -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<T> = std::result::Result<T, Error>;

View File

@@ -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() { 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(())
} }

32
src/render.rs Normal file
View File

@@ -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("<h1>Hello</h1>"));
assert!(html.contains("<em>test</em>"));
}
}

63
src/templates.rs Normal file
View File

@@ -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" }
}
}
}
}
}