- src/content.rs: output_path() now returns slug.html instead of slug/index.html for regular content. - src/templates.rs: All hrefs use explicit .html extension. Nav links point to index.html, blog.index.html, about.html. Blog post links use ./slug.html format. - src/main.rs: Adjust depth values (root=0, blog posts=1). No more directory-per-page overhead. file:// navigation works without directory listings. True suckless structure.
277 lines
8.2 KiB
Rust
277 lines
8.2 KiB
Rust
//! nrd.sh - Bespoke static site compiler.
|
|
//!
|
|
//! Transforms markdown content into a minimal static site.
|
|
|
|
mod content;
|
|
mod css;
|
|
mod error;
|
|
mod highlight;
|
|
mod render;
|
|
mod templates;
|
|
|
|
use crate::content::{Content, ContentKind};
|
|
use crate::error::{Error, Result};
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
fn main() {
|
|
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");
|
|
let static_dir = Path::new("static");
|
|
|
|
if !content_dir.exists() {
|
|
return Err(Error::ContentDirNotFound(content_dir.to_path_buf()));
|
|
}
|
|
|
|
// 0. Copy static assets
|
|
copy_static_assets(static_dir, output_dir)?;
|
|
|
|
// 1. Process blog posts
|
|
let mut posts = process_blog_posts(content_dir, output_dir)?;
|
|
|
|
// 2. Generate blog index (sorted by date, newest first)
|
|
posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
|
|
generate_blog_index(output_dir, &posts)?;
|
|
|
|
// 3. Process standalone pages (about, collab)
|
|
process_pages(content_dir, output_dir)?;
|
|
|
|
// 4. Process projects and generate project index
|
|
let mut projects = process_projects(content_dir)?;
|
|
projects.sort_by(|a, b| {
|
|
a.frontmatter
|
|
.weight
|
|
.unwrap_or(99)
|
|
.cmp(&b.frontmatter.weight.unwrap_or(99))
|
|
});
|
|
generate_projects_index(output_dir, &projects)?;
|
|
|
|
// 5. Generate homepage
|
|
generate_homepage(content_dir, output_dir)?;
|
|
|
|
eprintln!("done!");
|
|
Ok(())
|
|
}
|
|
|
|
/// Process all blog posts in content/blog/
|
|
fn process_blog_posts(content_dir: &Path, output_dir: &Path) -> Result<Vec<Content>> {
|
|
let blog_dir = content_dir.join("blog");
|
|
let mut posts = Vec::new();
|
|
|
|
for entry in walkdir::WalkDir::new(&blog_dir)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
e.path().extension().is_some_and(|ext| ext == "md")
|
|
&& e.path().file_name().is_some_and(|n| n != "_index.md")
|
|
})
|
|
{
|
|
let path = entry.path();
|
|
eprintln!("processing: {}", path.display());
|
|
|
|
let content = Content::from_path(path, ContentKind::Post)?;
|
|
let html_body = render::markdown_to_html(&content.body);
|
|
let page = templates::render_post(&content.frontmatter, &html_body, 1);
|
|
|
|
write_output(output_dir, content_dir, &content, page.into_string())?;
|
|
posts.push(content);
|
|
}
|
|
|
|
Ok(posts)
|
|
}
|
|
|
|
/// Generate the blog listing page
|
|
fn generate_blog_index(output_dir: &Path, posts: &[Content]) -> Result<()> {
|
|
let out_path = output_dir.join("blog/index.html");
|
|
eprintln!("generating: {}", out_path.display());
|
|
|
|
let page = templates::render_blog_index("Blog", posts, 1);
|
|
|
|
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
|
|
path: out_path.parent().unwrap().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());
|
|
Ok(())
|
|
}
|
|
|
|
/// Process standalone pages in content/ (about.md, collab.md)
|
|
fn process_pages(content_dir: &Path, output_dir: &Path) -> Result<()> {
|
|
for name in ["about.md", "collab.md"] {
|
|
let path = content_dir.join(name);
|
|
if path.exists() {
|
|
eprintln!("processing: {}", path.display());
|
|
|
|
let content = Content::from_path(&path, ContentKind::Page)?;
|
|
let html_body = render::markdown_to_html(&content.body);
|
|
let page = templates::render_page(&content.frontmatter, &html_body, 0);
|
|
|
|
write_output(output_dir, content_dir, &content, page.into_string())?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Load all project cards (without writing individual pages)
|
|
fn process_projects(content_dir: &Path) -> Result<Vec<Content>> {
|
|
let projects_dir = content_dir.join("projects");
|
|
let mut projects = Vec::new();
|
|
|
|
for entry in walkdir::WalkDir::new(&projects_dir)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
e.path().extension().is_some_and(|ext| ext == "md")
|
|
&& e.path().file_name().is_some_and(|n| n != "_index.md")
|
|
})
|
|
{
|
|
let content = Content::from_path(entry.path(), ContentKind::Project)?;
|
|
projects.push(content);
|
|
}
|
|
|
|
Ok(projects)
|
|
}
|
|
|
|
/// Generate the projects listing page
|
|
fn generate_projects_index(output_dir: &Path, projects: &[Content]) -> Result<()> {
|
|
let out_path = output_dir.join("projects/index.html");
|
|
eprintln!("generating: {}", out_path.display());
|
|
|
|
let page = templates::render_projects_index("Projects", projects, 1);
|
|
|
|
fs::create_dir_all(out_path.parent().unwrap()).map_err(|e| Error::CreateDir {
|
|
path: out_path.parent().unwrap().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());
|
|
Ok(())
|
|
}
|
|
|
|
/// Generate the homepage from content/_index.md
|
|
fn generate_homepage(content_dir: &Path, output_dir: &Path) -> Result<()> {
|
|
let index_path = content_dir.join("_index.md");
|
|
eprintln!("generating: homepage");
|
|
|
|
let content = Content::from_path(&index_path, ContentKind::Section)?;
|
|
let html_body = render::markdown_to_html(&content.body);
|
|
let page = templates::render_homepage(&content.frontmatter, &html_body, 0);
|
|
|
|
let out_path = output_dir.join("index.html");
|
|
|
|
fs::create_dir_all(output_dir).map_err(|e| Error::CreateDir {
|
|
path: output_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());
|
|
Ok(())
|
|
}
|
|
|
|
/// Write a content item to its output path
|
|
fn write_output(
|
|
output_dir: &Path,
|
|
content_dir: &Path,
|
|
content: &Content,
|
|
html: String,
|
|
) -> Result<()> {
|
|
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, html).map_err(|e| Error::WriteFile {
|
|
path: out_path.clone(),
|
|
source: e,
|
|
})?;
|
|
|
|
eprintln!(" → {}", out_path.display());
|
|
Ok(())
|
|
}
|
|
|
|
/// Copy static assets (CSS, images, etc.) to output directory.
|
|
/// CSS files are minified before writing.
|
|
fn copy_static_assets(static_dir: &Path, output_dir: &Path) -> Result<()> {
|
|
use crate::css::minify_css;
|
|
|
|
if !static_dir.exists() {
|
|
return Ok(()); // No static dir is fine
|
|
}
|
|
|
|
fs::create_dir_all(output_dir).map_err(|e| Error::CreateDir {
|
|
path: output_dir.to_path_buf(),
|
|
source: e,
|
|
})?;
|
|
|
|
for entry in walkdir::WalkDir::new(static_dir)
|
|
.into_iter()
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.file_type().is_file())
|
|
{
|
|
let src = entry.path();
|
|
let relative = src.strip_prefix(static_dir).unwrap();
|
|
let dest = output_dir.join(relative);
|
|
|
|
if let Some(parent) = dest.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| Error::CreateDir {
|
|
path: parent.to_path_buf(),
|
|
source: e,
|
|
})?;
|
|
}
|
|
|
|
// Minify CSS files, copy others directly
|
|
if src.extension().is_some_and(|ext| ext == "css") {
|
|
let css = fs::read_to_string(src).map_err(|e| Error::ReadFile {
|
|
path: src.to_path_buf(),
|
|
source: e,
|
|
})?;
|
|
let minified = minify_css(&css);
|
|
fs::write(&dest, &minified).map_err(|e| Error::WriteFile {
|
|
path: dest.clone(),
|
|
source: e,
|
|
})?;
|
|
eprintln!(
|
|
"minifying: {} → {} ({} → {} bytes)",
|
|
src.display(),
|
|
dest.display(),
|
|
css.len(),
|
|
minified.len()
|
|
);
|
|
} else {
|
|
fs::copy(src, &dest).map_err(|e| Error::WriteFile {
|
|
path: dest.clone(),
|
|
source: e,
|
|
})?;
|
|
eprintln!("copying: {} → {}", src.display(), dest.display());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|