use crate::generator::SiteGenerator;
use notify::EventKind;
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::broadcast;
use tracing::{debug, error, info};
use warp::Filter;
pub mod live_reload;
pub mod net;
pub mod static_files;
pub mod watcher;
pub mod websocket;
use live_reload::*;
use net::get_network_interfaces;
use watcher::start_watcher;
use websocket::*;
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>> {
self.generate_site()?;
self.start_file_watcher().await?;
let interfaces = get_network_interfaces();
let output_dir = self.output_dir.clone();
if self.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");
warp::serve(routes).run(([0, 0, 0, 0], self.port)).await;
} else {
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");
warp::serve(static_route)
.run(([0, 0, 0, 0], self.port))
.await;
}
Ok(())
}
fn generate_site(&self) -> Result<(), Box<dyn std::error::Error>> {
let generator =
SiteGenerator::new(&self.input_dir, &self.output_dir, self.theme_dir.as_ref())?;
generator.generate_site()?;
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);
start_watcher(input_dir.clone(), theme_dir.clone(), tx).await;
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());
let mut generator =
match SiteGenerator::new(&input_dir, &output_dir, theme_dir.as_ref()) {
Ok(g) => g,
Err(e) => {
error!("failed to initialize generator for watcher: {}", e);
return;
}
};
if let Err(e) = generator.scan_files() {
error!("initial scan failed in watcher: {}", e);
}
loop {
let event = match rx.recv().await {
Some(ev) => ev,
None => break,
};
use std::collections::HashMap;
let mut batched: HashMap<std::path::PathBuf, bool> = HashMap::new();
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);
}
while let Ok(Some(ev)) =
tokio::time::timeout(Duration::from_millis(250), rx.recv()).await
{
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);
}
}
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()
);
let mut did_anything = false;
for (path, is_remove) in batched.into_iter() {
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();
}
if live_reload {
if let Err(e) = inject_live_reload_script(&output_dir, port) {
error!("❌ Error injecting live reload script: {}", e);
}
}
info!("✅ Incremental build complete");
let _ = reload_tx.send(());
}
});
Ok(())
}
}