mod parser;
mod builder;
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::fs;
use std::sync::{Arc, atomic::{AtomicU64, Ordering}};
use tiny_http::{Server, Response, Header};
use notify::{Watcher, RecursiveMode, Event, EventKind};
use notify::event::ModifyKind;
use percent_encoding::percent_decode_str;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser)]
#[command(name = "guidebook")]
#[command(version)]
#[command(about = "HonKit/GitBook compatible static book generator", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(default_value = ".")]
path: PathBuf,
},
Build {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long, default_value = "_book")]
output: PathBuf,
},
Serve {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long, default_value = "4000")]
port: u16,
},
}
fn main() -> Result<()> {
check_for_updates();
let cli = Cli::parse();
match cli.command {
Commands::Init { path } => {
println!("Initializing book in {:?}", path);
Ok(())
}
Commands::Build { path, output } => {
println!("Building book from {:?} to {:?}", path, output);
builder::build(&path, &output)
}
Commands::Serve { path, port } => {
serve_book(&path, port)
}
}
}
fn serve_book(source: &PathBuf, port: u16) -> Result<()> {
let temp_dir = std::env::temp_dir().join("guidebook-serve");
if temp_dir.exists() {
fs::remove_dir_all(&temp_dir)?;
}
println!("Building book...");
builder::build(source, &temp_dir)?;
let version = Arc::new(AtomicU64::new(1));
let version_for_watcher = version.clone();
let source_for_watcher = source.clone();
let temp_dir_for_watcher = temp_dir.clone();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
let dominated: bool = matches!(
event.kind,
EventKind::Modify(ModifyKind::Data(_)) |
EventKind::Modify(ModifyKind::Name(_)) |
EventKind::Create(_) |
EventKind::Remove(_)
);
if dominated {
let dominated = event.paths.iter().any(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| matches!(e, "md" | "json" | "css" | "js" | "html"))
.unwrap_or(false)
});
if dominated {
println!("\n🔄 File changed, rebuilding...");
if let Err(e) = builder::build_with_options(&source_for_watcher, &temp_dir_for_watcher, true) {
eprintln!(" Build error: {}", e);
} else {
version_for_watcher.fetch_add(1, Ordering::SeqCst);
println!(" Rebuild complete!");
}
}
}
}
})?;
watcher.watch(source, RecursiveMode::Recursive)?;
let addr = format!("0.0.0.0:{}", port);
let server = Server::http(&addr).map_err(|e| {
if e.to_string().contains("Address already in use") {
anyhow::anyhow!(
"Port {} is already in use.\n Try: guidebook serve -p {}",
port,
port + 1
)
} else {
anyhow::anyhow!("Failed to start server: {}", e)
}
})?;
println!("\n📚 Serving book at http://localhost:{}/", port);
println!(" 🔥 Hot reload enabled - changes will auto-refresh");
println!(" Press Ctrl+C to stop\n");
let _watcher = watcher;
for request in server.incoming_requests() {
let url = request.url().to_string();
if url.starts_with("/__livereload") {
let client_version: u64 = url
.split("?v=")
.nth(1)
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let current_version = version.load(Ordering::SeqCst);
let response_body = if client_version < current_version {
format!(r#"{{"reload":true,"version":{}}}"#, current_version)
} else {
format!(r#"{{"reload":false,"version":{}}}"#, current_version)
};
let header = Header::from_bytes("Content-Type", "application/json").unwrap();
let response = Response::from_string(response_body).with_header(header);
let _ = request.respond(response);
continue;
}
let url_path = if url == "/" {
"/index.html".to_string()
} else if url.ends_with('/') {
format!("{}index.html", url)
} else {
url.clone()
};
let decoded_path = percent_decode_str(&url_path)
.decode_utf8_lossy()
.to_string();
let file_path = temp_dir.join(decoded_path.trim_start_matches('/'));
if file_path.exists() && file_path.is_file() {
let mut content = fs::read(&file_path).unwrap_or_default();
let content_type = get_content_type(&file_path);
if content_type.starts_with("text/html") {
let current_version = version.load(Ordering::SeqCst);
let livereload_script = format!(
r#"<script>
(function(){{
var version={};
function checkReload(){{
fetch('/__livereload?v='+version)
.then(function(r){{return r.json()}})
.then(function(data){{
if(data.reload){{
version=data.version;
location.reload();
}}
}})
.catch(function(){{}});
}}
setInterval(checkReload,1000);
}})();
</script></body>"#,
current_version
);
let html = String::from_utf8_lossy(&content);
let html = html.replace("</body>", &livereload_script);
content = html.into_bytes();
}
let header = Header::from_bytes("Content-Type", content_type).unwrap();
let response = Response::from_data(content).with_header(header);
let _ = request.respond(response);
} else {
let html_path = format!("{}.html", file_path.display());
let html_path = PathBuf::from(&html_path);
if html_path.exists() {
let content = fs::read(&html_path).unwrap_or_default();
let header = Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap();
let response = Response::from_data(content).with_header(header);
let _ = request.respond(response);
} else {
let response = Response::from_string("404 Not Found").with_status_code(404);
let _ = request.respond(response);
}
}
}
Ok(())
}
fn get_content_type(path: &PathBuf) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("js") => "application/javascript; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("ico") => "image/x-icon",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
_ => "application/octet-stream",
}
}
fn check_for_updates() {
std::thread::spawn(|| {
if let Some(latest) = get_latest_version() {
if is_newer_version(&latest, VERSION) {
eprintln!(
"\n📦 New version available: {} → {}\n Run: cargo install guidebook --force\n",
VERSION, latest
);
}
}
});
}
fn get_latest_version() -> Option<String> {
let response = ureq::get("https://crates.io/api/v1/crates/guidebook")
.set("User-Agent", &format!("guidebook/{}", VERSION))
.timeout(std::time::Duration::from_secs(2))
.call()
.ok()?;
let body = response.into_string().ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
json["crate"]["max_version"]
.as_str()
.map(String::from)
}
fn is_newer_version(latest: &str, current: &str) -> bool {
let parse = |v: &str| -> Vec<u32> {
v.split('.')
.filter_map(|p| p.parse().ok())
.collect()
};
let latest_parts = parse(latest);
let current_parts = parse(current);
for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
if l > c {
return true;
}
if l < c {
return false;
}
}
latest_parts.len() > current_parts.len()
}