krik 0.1.14

A fast static site generator written in Rust with internationalization, theming, and modern web features
Documentation
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::broadcast;
use warp::Filter;
use notify::{Watcher, RecursiveMode, Event, EventKind};
use crate::generator::SiteGenerator;
use tracing::{info, error, debug};

pub mod websocket;
pub mod static_files;
pub mod live_reload;

use websocket::*;
use live_reload::*;

pub struct DevServer {
    input_dir: PathBuf,
    output_dir: PathBuf,
    theme_dir: Option<PathBuf>,
    port: u16,
    live_reload: bool,
    reload_tx: broadcast::Sender<()>,
}

impl DevServer {
    pub fn new(
        input_dir: PathBuf,
        output_dir: PathBuf,
        theme_dir: Option<PathBuf>,
        port: u16,
        live_reload: bool,
    ) -> Self {
        let (reload_tx, _) = broadcast::channel(100);
        
        Self {
            input_dir,
            output_dir,
            theme_dir,
            port,
            live_reload,
            reload_tx,
        }
    }

    pub async fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
        // Initial site generation
        self.generate_site()?;
        
        // Start file watcher
        self.start_file_watcher().await?;
        
        // Get network interfaces
        let interfaces = get_network_interfaces();
        
        // Setup static file serving
        let output_dir = self.output_dir.clone();

        // Build routes based on live_reload setting
        if self.live_reload {
            // Setup with WebSocket for live reload
            let static_route = warp::fs::dir(output_dir.clone())
                .or(warp::path::end().and(warp::fs::file(output_dir.join("index.html"))));
            
            let reload_tx = self.reload_tx.clone();
            let ws_route = warp::path("__krik_reload")
                .and(warp::ws())
                .map(move |ws: warp::ws::Ws| {
                    let tx = reload_tx.clone();
                    ws.on_upgrade(move |websocket| handle_websocket(websocket, tx))
                });
            
            let routes = ws_route.or(static_route);
            
            info!("🚀 Krik development server started!");
            info!("📁 Serving: {}", self.output_dir.display());
            info!("👀 Watching: {}", self.input_dir.display());
            if let Some(ref theme_dir) = self.theme_dir {
                info!("👀 Watching theme: {}", theme_dir.display());
            }
            info!("🌐 Available on:");
            
            for interface in &interfaces {
                info!("   http://{}:{}", interface, self.port);
            }
            
            info!("✅ Live reload enabled");
            info!("\n💡 Press Ctrl+C to stop");

            // Start server with live reload
            warp::serve(routes)
                .bind(([0, 0, 0, 0], self.port))
                .await;
        } else {
            // Setup without WebSocket for static serving only
            let static_route = warp::fs::dir(output_dir.clone())
                .or(warp::path::end().and(warp::fs::file(output_dir.join("index.html"))));
            
            info!("🚀 Krik development server started!");
            info!("📁 Serving: {}", self.output_dir.display());
            info!("👀 Watching: {}", self.input_dir.display());
            if let Some(ref theme_dir) = self.theme_dir {
                info!("👀 Watching theme: {}", theme_dir.display());
            }
            info!("🌐 Available on:");
            
            for interface in &interfaces {
                info!("   http://{}:{}", interface, self.port);
            }
            
            info!("❌ Live reload disabled");
            info!("\n💡 Press Ctrl+C to stop");

            // Start server without live reload
            warp::serve(static_route)
                .bind(([0, 0, 0, 0], self.port))
                .await;
        }

        Ok(())
    }

    fn generate_site(&self) -> Result<(), Box<dyn std::error::Error>> {
        let mut generator = SiteGenerator::new(&self.input_dir, &self.output_dir, self.theme_dir.as_ref())?;
        generator.scan_files()?;
        generator.generate_site()?;
        
        // Conditionally inject live reload script into HTML files
        if self.live_reload {
            inject_live_reload_script(&self.output_dir, self.port)?;
        }
        
        Ok(())
    }

    async fn start_file_watcher(&self) -> Result<(), Box<dyn std::error::Error>> {
        let input_dir = self.input_dir.clone();
        let output_dir = self.output_dir.clone();
        let theme_dir = self.theme_dir.clone();
        let reload_tx = self.reload_tx.clone();
        let port = self.port;
        let live_reload = self.live_reload;

        tokio::spawn(async move {
            let (tx, mut rx) = tokio::sync::mpsc::channel(100);
            
            let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
                if let Ok(event) = res {
                    if matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)) {
                        debug!("notify event captured: kind={:?}, paths={:?}", event.kind, event.paths);
                        let _ = tx.blocking_send(event);
                    }
                }
            }).expect("Failed to create file watcher");

            watcher.watch(&input_dir, RecursiveMode::Recursive)
                .expect("Failed to watch input directory");

            if let Some(ref theme_dir) = theme_dir {
                watcher.watch(theme_dir, RecursiveMode::Recursive)
                    .expect("Failed to watch theme directory");
            }

            // Canonicalize watched roots to compare against canonical event paths
            let canonical_input_dir = std::fs::canonicalize(&input_dir).unwrap_or(input_dir.clone());
            let canonical_theme_dir = theme_dir.as_ref().and_then(|t| std::fs::canonicalize(t).ok());

            loop {
                // Wait for one event
                let event = match rx.recv().await {
                    Some(ev) => ev,
                    None => break,
                };

                // Start a short debounce window to coalesce bursty editor events
                use std::collections::HashMap;
                let mut batched: HashMap<std::path::PathBuf, bool> = HashMap::new(); // path -> is_remove

                let first_is_remove = matches!(event.kind, EventKind::Remove(_));
                for p in event.paths.iter() {
                    let canonical_path = std::fs::canonicalize(p).unwrap_or(p.clone());
                    batched
                        .entry(canonical_path)
                        .and_modify(|r| *r |= first_is_remove)
                        .or_insert(first_is_remove);
                }

                // Collect more events for 250ms of idle
                loop {
                    match tokio::time::timeout(Duration::from_millis(250), rx.recv()).await {
                        Ok(Some(ev)) => {
                            let is_remove = matches!(ev.kind, EventKind::Remove(_));
                            for p in ev.paths.iter() {
                                let canonical_path = std::fs::canonicalize(p).unwrap_or(p.clone());
                                batched
                                    .entry(canonical_path)
                                    .and_modify(|r| *r |= is_remove)
                                    .or_insert(is_remove);
                            }
                            continue;
                        }
                        _ => break,
                    }
                }

                // Log the batched set
                if !batched.is_empty() {
                    let mut dbg_paths: Vec<String> = batched
                        .iter()
                        .map(|(p, r)| format!("{} (remove={})", p.display(), r))
                        .collect();
                    dbg_paths.sort();
                    debug!("batched paths: {}", dbg_paths.join(", "));
                }
                info!("📝 {} changed path(s), running incremental build...", batched.len());

                // Run incremental for the batched unique paths
                if let Ok(generator) = SiteGenerator::new(&input_dir, &output_dir, theme_dir.as_ref()) {
                    let mut did_anything = false;
                    for (path, is_remove) in batched.into_iter() {
                        // Only handle changes under input_dir or theme_dir
                        let relevant = path.starts_with(&canonical_input_dir) || canonical_theme_dir.as_ref().map(|t| path.starts_with(t)).unwrap_or(false);
                        if !relevant {
                            debug!("skipping unrelated change: {}", path.display());
                            continue;
                        }
                        debug!("incremental build for {} (remove={})", path.display(), is_remove);
                        match generator.generate_incremental_for_path(&path, is_remove) {
                            Ok(()) => { did_anything = true; }
                            Err(e) => {
                                error!("❌ Incremental generation failed for {}: {}", path.display(), e);
                                if let Err(full_err) = generator.generate_site() {
                                    error!("❌ Full regeneration after failure also failed: {}", full_err);
                                } else {
                                    debug!("fallback full regeneration completed after incremental failure");
                                    did_anything = true;
                                }
                            }
                        }
                    }

                    if !did_anything {
                        let _ = generator.generate_site();
                    }

                    // Conditionally inject live reload script
                    if live_reload {
                        if let Err(e) = inject_live_reload_script(&output_dir, port) {
                            error!("❌ Error injecting live reload script: {}", e);
                            continue;
                        }
                    }

                    info!("✅ Incremental build complete");
                    let _ = reload_tx.send(());
                }
            }
        });

        Ok(())
    }
}

fn get_network_interfaces() -> Vec<String> {
    let mut interfaces = vec!["127.0.0.1".to_string()];
    
    // Try to get local network IP
    if let Ok(local_ip) = local_ip_address::local_ip() {
        if local_ip.to_string() != "127.0.0.1" {
            interfaces.push(local_ip.to_string());
        }
    }
    
    // Try to get all network interfaces
    if let Ok(network_interfaces) = local_ip_address::list_afinet_netifas() {
        for (_name, ip) in network_interfaces {
            let ip_str = ip.to_string();
            if !ip_str.starts_with("127.") && !ip_str.starts_with("169.254.") && !interfaces.contains(&ip_str) {
                interfaces.push(ip_str);
            }
        }
    }
    
    interfaces
}