Files
sukr/src/main.rs
Timothy DeHerrera 69cd81621f feat(docs): create sukr documentation site with fixes
Self-documenting docs site built with sukr itself (dogfooding):

Core changes:
- Rename package from nrd-sh to sukr
- Move personal site to sites/nrd.sh/
- Update AGENTS.md and README.md

Documentation site (docs/):
- Add site.toml with sukr.io base URL
- Create docs-specific templates with sidebar navigation
- Add dark theme CSS with syntax highlighting colors
- Document all features: templates, sections, syntax highlighting,
  mermaid diagrams, and LaTeX math rendering

Bug fixes:
- Render individual pages for all sections (not just blog type)
- Add #[source] error chaining for Tera template errors
- Print full error chain in main() for better debugging
2026-01-31 16:14:04 -07:00

364 lines
11 KiB
Rust

//! sukr - Minimal static site compiler.
//!
//! Suckless, Rust, zero JS. Transforms markdown into static HTML.
mod config;
mod content;
mod css;
mod error;
mod feed;
mod highlight;
mod math;
mod mermaid;
mod render;
mod template_engine;
use crate::content::{discover_nav, discover_sections, Content, ContentKind, NavItem};
use crate::error::{Error, Result};
use crate::template_engine::{ContentContext, TemplateEngine};
use std::fs;
use std::path::{Path, PathBuf};
const USAGE: &str = "\
sukr - Minimal static site compiler
USAGE:
sukr [OPTIONS]
OPTIONS:
-c, --config <FILE> Path to site.toml config file (default: ./site.toml)
-h, --help Print this help message
";
fn main() {
match parse_args() {
Ok(Some(config_path)) => {
if let Err(e) = run(&config_path) {
eprintln!("error: {e}");
// Print full error chain
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
eprintln!(" caused by: {cause}");
source = std::error::Error::source(cause);
}
std::process::exit(1);
}
}
Ok(None) => {} // --help was printed
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
/// Parse command-line arguments. Returns None if --help was requested.
fn parse_args() -> std::result::Result<Option<PathBuf>, String> {
let args: Vec<_> = std::env::args().collect();
let mut config_path = PathBuf::from("site.toml");
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print!("{USAGE}");
return Ok(None);
}
"-c" | "--config" => {
if i + 1 >= args.len() {
return Err("--config requires an argument".to_string());
}
config_path = PathBuf::from(&args[i + 1]);
i += 2;
}
arg => {
return Err(format!("unknown argument: {arg}"));
}
}
}
Ok(Some(config_path))
}
fn run(config_path: &Path) -> Result<()> {
// Load site configuration
let config = config::SiteConfig::load(config_path)?;
// Resolve paths relative to config file location
let base_dir = config_path.parent().unwrap_or(Path::new("."));
let content_dir = base_dir.join(&config.paths.content);
let output_dir = base_dir.join(&config.paths.output);
let static_dir = base_dir.join(&config.paths.static_dir);
let template_dir = base_dir.join(&config.paths.templates);
if !content_dir.exists() {
return Err(Error::ContentDirNotFound(content_dir.to_path_buf()));
}
// Load Tera templates
let engine = TemplateEngine::new(&template_dir)?;
// Discover navigation from filesystem
let nav = discover_nav(&content_dir)?;
// 0. Copy static assets
copy_static_assets(&static_dir, &output_dir)?;
// 1. Discover and process all sections
let sections = discover_sections(&content_dir)?;
let mut all_posts = Vec::new(); // For feed generation
for section in &sections {
eprintln!("processing section: {}", section.name);
// Collect and sort items in this section
let mut items = section.collect_items()?;
// Sort based on section type
match section.section_type.as_str() {
"blog" => {
// Blog: sort by date, newest first
items.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
all_posts.extend(items.iter().cloned());
}
"projects" => {
// Projects: sort by weight
items.sort_by(|a, b| {
a.frontmatter
.weight
.unwrap_or(99)
.cmp(&b.frontmatter.weight.unwrap_or(99))
});
}
_ => {
// Default: sort by weight then title
items.sort_by(|a, b| {
a.frontmatter
.weight
.unwrap_or(50)
.cmp(&b.frontmatter.weight.unwrap_or(50))
.then_with(|| a.frontmatter.title.cmp(&b.frontmatter.title))
});
}
}
// Render individual content pages for all sections
for item in &items {
eprintln!(" processing: {}", item.slug);
let html_body = render::markdown_to_html(&item.body);
let page_path = format!("/{}", item.output_path(&content_dir).display());
let html = engine.render_content(item, &html_body, &page_path, &config, &nav)?;
write_output(&output_dir, &content_dir, item, html)?;
}
// Render section index
let page_path = format!("/{}/index.html", section.name);
let item_contexts: Vec<_> = items
.iter()
.map(|c| ContentContext::from_content(c, &content_dir))
.collect();
let html = engine.render_section(
&section.index,
&section.section_type,
&item_contexts,
&page_path,
&config,
&nav,
)?;
let out_path = output_dir.join(&section.name).join("index.html");
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, html).map_err(|e| Error::WriteFile {
path: out_path.clone(),
source: e,
})?;
eprintln!("{}", out_path.display());
}
// 2. Generate Atom feed (blog posts only)
if !all_posts.is_empty() {
generate_feed(&output_dir, &all_posts, &config, &content_dir)?;
}
// 3. Process standalone pages (discovered dynamically)
process_pages(&content_dir, &output_dir, &config, &nav, &engine)?;
// 4. Generate homepage
generate_homepage(&content_dir, &output_dir, &config, &nav, &engine)?;
eprintln!("done!");
Ok(())
}
/// Generate the Atom feed
fn generate_feed(
output_dir: &Path,
posts: &[Content],
config: &config::SiteConfig,
content_dir: &Path,
) -> Result<()> {
let out_path = output_dir.join("feed.xml");
eprintln!("generating: {}", out_path.display());
let feed_xml = feed::generate_atom_feed(posts, config, content_dir);
fs::write(&out_path, feed_xml).map_err(|e| Error::WriteFile {
path: out_path.clone(),
source: e,
})?;
eprintln!("{}", out_path.display());
Ok(())
}
/// Process standalone pages in content/ (top-level .md files excluding _index.md)
fn process_pages(
content_dir: &Path,
output_dir: &Path,
config: &config::SiteConfig,
nav: &[NavItem],
engine: &TemplateEngine,
) -> Result<()> {
// Dynamically discover top-level .md files (except _index.md)
let entries = fs::read_dir(content_dir).map_err(|e| Error::ReadFile {
path: content_dir.to_path_buf(),
source: e,
})?;
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file()
&& path.extension().is_some_and(|ext| ext == "md")
&& path.file_name().is_some_and(|n| n != "_index.md")
{
eprintln!("processing: {}", path.display());
let content = Content::from_path(&path, ContentKind::Page)?;
let html_body = render::markdown_to_html(&content.body);
let page_path = format!("/{}", content.output_path(content_dir).display());
let html = engine.render_page(&content, &html_body, &page_path, config, nav)?;
write_output(output_dir, content_dir, &content, html)?;
}
}
Ok(())
}
/// Generate the homepage from content/_index.md
fn generate_homepage(
content_dir: &Path,
output_dir: &Path,
config: &config::SiteConfig,
nav: &[NavItem],
engine: &TemplateEngine,
) -> 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 html = engine.render_page(&content, &html_body, "/index.html", config, nav)?;
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, html).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(())
}