bim 0.1.0

A content-first static site generator for blogs, digital gardens, and knowledge bases
Documentation
use crate::config::Config;
use crate::discovery::discover_site;
use crate::render::build_site;
use anyhow::Result;
use axum::Router;
use notify_debouncer_mini::new_debouncer;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::mpsc::channel;
use std::time::Duration;
use tower_http::services::ServeDir;

pub async fn serve(config: &Config) -> Result<()> {
    // Initial build
    tracing::info!("Building site...");
    let site = discover_site(config)?;
    build_site(&site, config)?;

    // Start server
    let addr: SocketAddr = format!("{}:{}", config.server_host, config.server_port).parse()?;

    let app = Router::new().fallback_service(ServeDir::new(&config.output_dir));

    tracing::info!("Serving at http://{}", addr);

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

pub async fn serve_with_watch(config: &Config) -> Result<()> {
    // Initial build
    tracing::info!("Building site...");
    let site = discover_site(config)?;
    build_site(&site, config)?;

    let config_clone = config.clone();

    // Spawn watcher in background
    tokio::spawn(async move {
        if let Err(e) = run_watcher(&config_clone) {
            tracing::error!("Watcher error: {}", e);
        }
    });

    // Start server
    let addr: SocketAddr = format!("{}:{}", config.server_host, config.server_port).parse()?;

    let app = Router::new().fallback_service(ServeDir::new(&config.output_dir));

    tracing::info!("Serving at http://{} (watching for changes)", addr);

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

fn run_watcher(config: &Config) -> Result<()> {
    let (tx, rx) = channel();

    let mut debouncer = new_debouncer(Duration::from_millis(500), tx)?;

    debouncer
        .watcher()
        .watch(&config.source_dir, notify::RecursiveMode::Recursive)?;

    loop {
        match rx.recv() {
            Ok(Ok(events)) => {
                let relevant = events.iter().any(|e| !should_ignore(&e.path));

                if !relevant {
                    continue;
                }

                tracing::info!("Changes detected, rebuilding...");

                match discover_site(config) {
                    Ok(site) => match build_site(&site, config) {
                        Ok(()) => tracing::info!("Rebuild complete!"),
                        Err(e) => tracing::error!("Build error: {}", e),
                    },
                    Err(e) => tracing::error!("Discovery error: {}", e),
                }
            }
            Ok(Err(e)) => {
                tracing::warn!("Watch error: {:?}", e);
            }
            Err(e) => {
                tracing::error!("Channel error: {}", e);
                break;
            }
        }
    }

    Ok(())
}

fn should_ignore(path: &Path) -> bool {
    let path_str = path.to_string_lossy();

    const IGNORED: &[&str] = &[".git", ".obsidian", "node_modules", "target", "dist"];

    for pattern in IGNORED {
        if path_str.contains(pattern) {
            return true;
        }
    }

    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        if name.starts_with('.') {
            return true;
        }
    }

    false
}