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:
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