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
|
# Local Netlify folder
|
||||||
.netlify
|
.netlify
|
||||||
|
|
||||||
.direnv
|
.direnv
|
||||||
target/
|
target/
|
||||||
|
.repomap*
|
||||||
|
public/
|
||||||
|
|||||||
@@ -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
65
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
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() {
|
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