1use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29
30use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
31use anyhow::{anyhow, Context, Result};
32use console::style;
33use webui_dev_server::path::normalize_base_path;
34use webui_dev_server::{
35 default_ignore_paths, serve_static_file, spawn_rebuild_worker, spawn_watcher, sse_handler,
36 LiveReload, NotFoundStrategy, StaticServeConfig, WatchConfig, WatcherHandle,
37};
38
39use crate::build::{build_docs_with_cache, BuildCache};
40use crate::types::DocsConfig;
41
42const DEBOUNCE_DURATION: Duration = Duration::from_millis(50);
45
46const RELOAD_PATH: &str = "/__webui_press/livereload";
49
50pub struct ServeConfig {
52 pub config: DocsConfig,
53 pub config_dir: PathBuf,
54 pub template_dir: PathBuf,
55 pub config_path: PathBuf,
56 pub host: String,
57 pub port: u16,
58}
59
60pub async fn run_serve(opts: ServeConfig) -> Result<()> {
62 let ServeConfig {
63 config,
64 config_dir,
65 template_dir,
66 config_path,
67 host,
68 port,
69 } = opts;
70 let base_path = normalize_base_path(&config.base_path);
71 let out_dir = PathBuf::from(&config.out_dir);
75 let content_dir_str = config.content_dir.clone();
76 let public_dir_str = config.public_dir.clone();
77
78 println!(
79 "{} {}",
80 style("⚡").cyan().bold(),
81 style("WebUI Press dev server").bold()
82 );
83 println!();
84
85 let initial_cache: BuildCache = {
91 let cfg = clone_config_via_reparse(&config_path)?;
92 let cd = config_dir.clone();
93 let td = template_dir.clone();
94 tokio::task::spawn_blocking(move || -> Result<BuildCache> {
95 let mut cache = BuildCache::new();
96 build_docs_with_cache(&cfg, &cd, &td, &mut cache)
97 .map_err(|e| anyhow!("initial build failed: {e}"))?;
98 cache.set_dev_rebuild();
104 Ok(cache)
105 })
106 .await
107 .map_err(|e| anyhow!("build task panicked: {e}"))??
108 };
109 let _ = config;
112
113 let livereload = LiveReload::new(RELOAD_PATH);
114
115 let cache = Arc::new(Mutex::new(initial_cache));
119
120 let tick_tx = {
121 let config_path = config_path.clone();
122 let config_dir = config_dir.clone();
123 let template_dir = template_dir.clone();
124 let cache = cache.clone();
125 spawn_rebuild_worker(livereload.clone(), move || {
126 let cfg = clone_config_via_reparse(&config_path)
127 .map_err(|e| format!("config reload failed: {e}"))?;
128 let mut guard = cache
129 .lock()
130 .unwrap_or_else(|poisoned| poisoned.into_inner());
131 build_docs_with_cache(&cfg, &config_dir, &template_dir, &mut guard)
132 .map_err(|e| format!("{e}"))?;
133 Ok(())
134 })
135 };
136
137 let _watcher: WatcherHandle = {
138 let tx = tick_tx.clone();
139 let watched = watch_paths(
140 &config_dir,
141 &config_path,
142 Path::new(&content_dir_str),
143 Path::new(&public_dir_str),
144 );
145 let mut ignore = default_ignore_paths();
146 ignore.push(out_dir.clone());
147 ignore.push(PathBuf::from(".webui-press/cache"));
148 spawn_watcher(
149 WatchConfig {
150 paths: watched,
151 ignore,
152 debounce: DEBOUNCE_DURATION,
153 },
154 move |_paths: Vec<PathBuf>| {
155 let _ = tx.try_send(());
159 },
160 )?
161 };
162
163 let static_cfg = StaticServeConfig {
164 root: out_dir.clone(),
165 base_path: base_path.clone(),
166 livereload: livereload.clone(),
167 not_found: NotFoundStrategy::File(PathBuf::from("404.html")),
168 };
169 let static_data = web::Data::new(static_cfg);
170 let lr_data = web::Data::new(livereload.clone());
171
172 let bind = format!("{host}:{port}");
173 let url = format!("http://{host}:{port}{base_path}");
174 println!(
175 " {} {}",
176 style("➜").green().bold(),
177 style(&url).cyan().underlined()
178 );
179 println!();
180
181 HttpServer::new(move || {
182 App::new()
183 .app_data(static_data.clone())
184 .app_data(lr_data.clone())
185 .route(RELOAD_PATH, web::get().to(sse_handler))
186 .default_service(web::get().to(static_handler))
187 })
188 .bind(&bind)
189 .with_context(|| format!("Cannot bind {bind}"))?
190 .run()
191 .await
192 .context("Dev server failed")?;
193
194 drop(_watcher);
196 Ok(())
197}
198
199async fn static_handler(req: HttpRequest, cfg: web::Data<StaticServeConfig>) -> HttpResponse {
203 serve_static_file(&req, cfg.get_ref()).await
204}
205
206fn watch_paths(
233 config_dir: &Path,
234 config_path: &Path,
235 content_dir: &Path,
236 public_dir: &Path,
237) -> Vec<PathBuf> {
238 let mut paths: Vec<PathBuf> = Vec::with_capacity(4);
239 let mut push_dedup = |candidate: PathBuf| {
240 if candidate.as_os_str().is_empty() {
241 return;
242 }
243 let candidate = candidate.canonicalize().unwrap_or(candidate);
249 if paths.iter().any(|p| candidate.starts_with(p)) {
250 return;
251 }
252 paths.retain(|p| !p.starts_with(&candidate));
253 paths.push(candidate);
254 };
255 push_dedup(config_dir.to_path_buf());
256 if let Some(parent) = config_path.parent() {
257 push_dedup(parent.to_path_buf());
258 }
259 push_dedup(content_dir.to_path_buf());
260 push_dedup(public_dir.to_path_buf());
261 paths
262}
263
264fn clone_config_via_reparse(config_path: &Path) -> Result<DocsConfig> {
267 let s = std::fs::read_to_string(config_path)
268 .with_context(|| format!("Cannot read {}", config_path.display()))?;
269 serde_json::from_str(&s).with_context(|| format!("Invalid JSON in {}", config_path.display()))
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn watch_paths_dedupes_nested_paths() {
278 let cfg = Path::new("/proj/docs/.webui-press");
279 let config_file = Path::new("/proj/docs/.webui-press/config.json");
280 let content = Path::new("/proj/docs/.webui-press");
281 let public = Path::new("/proj/docs/.webui-press/public");
282 let paths = watch_paths(cfg, config_file, content, public);
283 assert_eq!(paths, vec![PathBuf::from("/proj/docs/.webui-press")]);
286 }
287
288 #[test]
289 fn watch_paths_includes_sibling_content_dir() {
290 let cfg = Path::new("docs/.webui-press");
293 let config_file = Path::new("docs/.webui-press/config.json");
294 let content = Path::new("docs");
295 let public = Path::new("docs/.webui-press/public");
296 let paths = watch_paths(cfg, config_file, content, public);
297 assert_eq!(paths, vec![PathBuf::from("docs")]);
300 }
301
302 #[test]
303 fn watch_paths_includes_external_content_dir() {
304 let cfg = Path::new("/proj/.webui-press");
306 let config_file = Path::new("/proj/.webui-press/config.json");
307 let content = Path::new("/proj/markdown");
308 let public = Path::new("/proj/.webui-press/public");
309 let paths = watch_paths(cfg, config_file, content, public);
310 assert_eq!(
311 paths,
312 vec![
313 PathBuf::from("/proj/.webui-press"),
314 PathBuf::from("/proj/markdown"),
315 ]
316 );
317 }
318}