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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,5 +3,8 @@ target
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
.direnv
|
||||
target/
|
||||
.repomap*
|
||||
public/
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
65
Cargo.lock
generated
65
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -9,3 +9,5 @@ version = "0.1.0"
|
||||
gray_matter = "0.2"
|
||||
maud = "0.26"
|
||||
pulldown-cmark = "0.12"
|
||||
thiserror = "2"
|
||||
walkdir = "2"
|
||||
|
||||
115
src/content.rs
Normal file
115
src/content.rs
Normal 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
42
src/error.rs
Normal 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>;
|
||||
65
src/main.rs
65
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(())
|
||||
}
|
||||
|
||||
32
src/render.rs
Normal file
32
src/render.rs
Normal 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
63
src/templates.rs
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user