veltox 0.1.0

A fast, themeable static site generator written in Rust — ideal for documentation and blogs.
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
use pulldown_cmark::{Parser, html};
use serde::Deserialize;
use tera::{Tera, Context};
use serde_json::json;

#[derive(Debug, Deserialize)]
struct Config {
    theme: Option<String>,
}

#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
    title: Option<String>,
    date: Option<String>,
    template: Option<String>,
    draft: Option<bool>,
}

fn main() {
    let input_root = Path::new("content");
    let output_root = Path::new("public");
    let config = load_config("veltox.toml");
    let theme_name = config.theme.unwrap_or_else(|| "docs".to_string());

    let template_path = format!("themes/{}/templates/**/*", theme_name);
    let tera = Tera::new(&template_path).expect("Failed to load templates");

    // Copy theme.css
    let css_src = format!("themes/{}/theme.css", theme_name);
    let css_dst = output_root.join("theme.css");
    if Path::new(&css_src).exists() {
        fs::create_dir_all(output_root).expect("Failed to create public directory");
        fs::copy(&css_src, &css_dst).expect("Failed to copy theme.css");
    }

    // Copy all templates into public/
    let template_dir = format!("themes/{}/templates", theme_name);
    if Path::new(&template_dir).exists() {
        for entry in WalkDir::new(&template_dir)
            .into_iter()
            .filter_map(Result::ok)
            .filter(|e| e.path().extension().map_or(false, |ext| ext == "html"))
        {
            let src = entry.path();
            let filename = src.file_name().unwrap();
            let dst = output_root.join(filename);
            fs::copy(src, dst).expect("Failed to copy template file");
        }
    }

    let sidebar = generate_sidebar(input_root);
    let mut search_index = Vec::new();

    for entry in WalkDir::new(input_root)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
    {
        let input_path = entry.path();
        let relative_path = input_path.strip_prefix(input_root).unwrap();
        let mut output_path = output_root.join(relative_path);
        output_path.set_extension("html");

        if let Some(parent) = output_path.parent() {
            fs::create_dir_all(parent).expect("Failed to create output directory");
        }

        let raw = fs::read_to_string(input_path).expect("Failed to read markdown file");
        let (frontmatter, markdown) = split_frontmatter(&raw);

        if frontmatter.draft.unwrap_or(false) {
            println!("Skipping draft: {:?}", input_path);
            continue;
        }

        let parser = Parser::new(markdown);
        let mut html_output = String::new();
        html::push_html(&mut html_output, parser);

        let mut context = Context::new();
        context.insert("title", &frontmatter.title);
        context.insert("date", &frontmatter.date);
        context.insert("content", &html_output);
        context.insert("sidebar", &sidebar);

        let template_name = frontmatter.template.unwrap_or_else(|| "page.html".to_string());
        let rendered = tera.render(&template_name, &context).expect("Template rendering failed");

        fs::write(output_path, rendered).expect("Failed to write HTML file");

        let url = relative_path.with_extension("html").to_string_lossy().replace('\\', "/");
        let title = frontmatter.title.unwrap_or_else(|| {
            relative_path.file_stem().unwrap().to_string_lossy().to_string()
        });

        search_index.push(json!({
            "title": title,
            "url": format!("./{}", url),
            "content": markdown,
        }));
    }

    let index_json = serde_json::to_string_pretty(&search_index).expect("Failed to serialize search index");
    fs::write(output_root.join("search-index.json"), index_json).expect("Failed to write search index");

    println!("✅ Veltox build complete with theme: {}", theme_name);
}

fn split_frontmatter(content: &str) -> (Frontmatter, &str) {
    if content.starts_with("---") {
        let parts: Vec<&str> = content.splitn(3, "---").collect();
        if parts.len() == 3 {
            let fm_str = parts[1];
            let md_str = parts[2];
            let frontmatter: Frontmatter = serde_yaml::from_str(fm_str).unwrap_or_default();
            return (frontmatter, md_str.trim());
        }
    }
    (Frontmatter::default(), content)
}

fn load_config(path: &str) -> Config {
    if let Ok(raw) = fs::read_to_string(path) {
        toml::from_str(&raw).unwrap_or(Config { theme: None })
    } else {
        Config { theme: None }
    }
}

fn generate_sidebar(input_root: &Path) -> Vec<HashMap<String, String>> {
    let mut sidebar = Vec::new();

    for entry in WalkDir::new(input_root)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.path().extension().map_or(false, |ext| ext == "md"))
    {
        let path = entry.path();
        let relative = path.strip_prefix(input_root).unwrap();
        let url = relative.with_extension("html").to_string_lossy().replace('\\', "/");

        let raw = fs::read_to_string(path).unwrap_or_default();
        let (frontmatter, _) = split_frontmatter(&raw);
        let title = frontmatter.title.unwrap_or_else(|| {
            relative.file_stem().unwrap().to_string_lossy().to_string()
        });

        let mut item = HashMap::new();
        item.insert("title".to_string(), title);
        item.insert("url".to_string(), format!("./{}", url));
        sidebar.push(item);
    }

    sidebar
}