//! 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 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, 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 §ions { 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( §ion.index, §ion.section_type, &item_contexts, &page_path, &config, &nav, )?; let out_path = output_dir.join(§ion.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(()) }