use std::{
path::Path,
sync::Arc,
time::{Duration, Instant},
};
use color_eyre::eyre::{Result, WrapErr};
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, event::ModifyKind};
use tokio::{net::TcpListener, sync::mpsc};
use typstify_core::Config;
use typstify_generator::{BuildStats, Builder};
use super::check::quick_validate;
use crate::server::{LIVERELOAD_SCRIPT, ServerState, create_router};
const DEBOUNCE_MS: u64 = 200;
pub async fn run(config_path: &Path, port: u16, open_browser: bool) -> Result<()> {
tracing::info!(?config_path, port, "Starting watch mode");
let mut config = Config::load(config_path).wrap_err("Failed to load configuration")?;
let warnings = quick_validate(&config);
if !warnings.is_empty() {
println!();
println!(" Warnings:");
for warn in &warnings {
println!(" ⚠ {warn}");
}
println!();
}
config.build.drafts = true;
let output_dir = Path::new(&config.build.output_dir).to_path_buf();
let content_dir_path = Path::new("content").to_path_buf();
tracing::info!("Running initial build...");
let builder = Builder::new(config.clone(), &content_dir_path, &output_dir);
let stats = inject_livereload_and_build(&builder, &output_dir)?;
print_build_stats(&stats);
let state = Arc::new(ServerState::new());
let (tx, mut rx) = mpsc::channel::<()>(16);
let watcher_tx = tx.clone();
let content_dir = Path::new("content").to_path_buf();
let templates_dir = Path::new("templates").to_path_buf();
let style_dir = Path::new("style").to_path_buf();
let mut watcher = RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
if matches!(
event.kind,
EventKind::Modify(ModifyKind::Data(_))
| EventKind::Create(_)
| EventKind::Remove(_)
) {
let _ = watcher_tx.blocking_send(());
}
}
},
notify::Config::default(),
)
.wrap_err("Failed to create file watcher")?;
if content_dir.exists() {
watcher
.watch(&content_dir, RecursiveMode::Recursive)
.wrap_err("Failed to watch content directory")?;
tracing::debug!("Watching content directory");
}
if templates_dir.exists() {
watcher
.watch(&templates_dir, RecursiveMode::Recursive)
.wrap_err("Failed to watch templates directory")?;
tracing::debug!("Watching templates directory");
}
if style_dir.exists() {
watcher
.watch(&style_dir, RecursiveMode::Recursive)
.wrap_err("Failed to watch style directory")?;
tracing::debug!("Watching style directory");
}
let rebuild_state = state.clone();
let rebuild_config = config.clone();
let rebuild_output = output_dir.clone();
let rebuild_content = content_dir_path.clone();
tokio::spawn(async move {
let mut last_rebuild = Instant::now();
while rx.recv().await.is_some() {
if last_rebuild.elapsed() < Duration::from_millis(DEBOUNCE_MS) {
continue;
}
while rx.try_recv().is_ok() {}
println!();
println!(" File change detected, rebuilding...");
let builder = Builder::new(rebuild_config.clone(), &rebuild_content, &rebuild_output);
match inject_livereload_and_build(&builder, &rebuild_output) {
Ok(stats) => {
println!(
" ✓ Rebuilt {} pages in {}ms",
stats.pages + stats.taxonomy_pages + stats.auto_pages,
stats.duration_ms
);
rebuild_state.notify_reload();
}
Err(e) => {
tracing::error!("Rebuild failed: {e}");
eprintln!(" ✗ Rebuild failed: {e}");
}
}
last_rebuild = Instant::now();
}
});
let app = create_router(&output_dir, state);
let addr = format!("127.0.0.1:{port}");
let listener = TcpListener::bind(&addr)
.await
.wrap_err_with(|| format!("Failed to bind to {addr}"))?;
println!();
println!(" Dev server running at http://{addr}");
println!(" Press Ctrl+C to stop");
println!();
if open_browser {
let _ = open::that(format!("http://{addr}"));
}
let _watcher = watcher;
axum::serve(listener, app).await.wrap_err("Server error")?;
Ok(())
}
fn print_build_stats(stats: &BuildStats) {
let total_pages = stats.pages + stats.taxonomy_pages + stats.auto_pages;
println!();
println!(" Build Statistics:");
println!(" ─────────────────────────────────");
println!(" Pages: {:>6}", stats.pages);
println!(" Taxonomies: {:>6}", stats.taxonomy_pages);
println!(" Auto Pages: {:>6}", stats.auto_pages);
println!(" Redirects: {:>6}", stats.redirects);
println!(" Assets: {:>6}", stats.assets);
println!(" ─────────────────────────────────");
println!(" Total: {total_pages:>6} pages");
println!(" Duration: {:>6}ms", stats.duration_ms);
println!();
}
fn inject_livereload_and_build(builder: &Builder, output_dir: &Path) -> Result<BuildStats> {
let stats = builder.build().wrap_err("Build failed")?;
inject_livereload_into_html(output_dir)?;
tracing::debug!(?stats, "Build completed");
Ok(stats)
}
fn inject_livereload_into_html(output_dir: &Path) -> Result<()> {
use std::fs;
for entry in walkdir::WalkDir::new(output_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "html"))
{
let path = entry.path();
let content = fs::read_to_string(path)?;
if !content.contains("__livereload") {
let modified = content.replace("</body>", &format!("{LIVERELOAD_SCRIPT}</body>"));
fs::write(path, modified)?;
}
}
Ok(())
}