1use crate::error::{PathErrorExt, SsgError};
21use crate::plugin::{Plugin, PluginContext};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25pub const DEFAULT_PORT: u16 = 35729;
27
28const MAX_FILES: usize = 50_000;
30
31const MARKER: &str = "ssg-livereload";
33
34#[derive(Debug, Clone, Copy)]
50pub struct LiveReloadPlugin {
51 port: u16,
53}
54
55impl LiveReloadPlugin {
56 #[must_use]
67 pub const fn new() -> Self {
68 Self { port: DEFAULT_PORT }
69 }
70
71 #[must_use]
82 pub const fn with_port(port: u16) -> Self {
83 Self { port }
84 }
85
86 #[must_use]
97 pub const fn port(&self) -> u16 {
98 self.port
99 }
100}
101
102impl Default for LiveReloadPlugin {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl Plugin for LiveReloadPlugin {
109 fn name(&self) -> &'static str {
110 "livereload"
111 }
112
113 fn on_serve(&self, ctx: &PluginContext) -> Result<(), SsgError> {
114 if !ctx.site_dir.exists() {
115 return Ok(());
116 }
117
118 let html_files = collect_html_files(&ctx.site_dir)?;
119 if html_files.is_empty() {
120 return Ok(());
121 }
122
123 for path in &html_files {
124 inject_livereload(path, self.port)?;
125 }
126
127 println!(
128 "[livereload] Injected live-reload script into {} HTML file(s) (port {})",
129 html_files.len(),
130 self.port,
131 );
132 Ok(())
133 }
134}
135
136fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
138 crate::walk::walk_files_bounded_count(dir, "html", MAX_FILES)
139}
140
141fn inject_livereload(path: &Path, port: u16) -> Result<(), SsgError> {
152 let html = fs::read_to_string(path).with_path(path)?;
153
154 if html.contains(MARKER) {
155 return Ok(()); }
157
158 let script = livereload_script(port);
159
160 let injected = if let Some(pos) = html.rfind("</body>") {
161 format!("{}{}{}", &html[..pos], script, &html[pos..])
162 } else {
163 format!("{html}{script}")
164 };
165
166 fs::write(path, injected).with_path(path)?;
167 Ok(())
168}
169
170fn livereload_script(port: u16) -> String {
172 format!(
173 r"
174<!-- SSG Live-Reload -->
175<script data-ssg-livereload>
176(function(){{
177 var url='ws://localhost:{port}',delay=1000,maxDelay=10000,indicator=null;
178 try{{var sp=sessionStorage.getItem('ssg-scroll');if(sp){{sessionStorage.removeItem('ssg-scroll');var p=JSON.parse(sp);setTimeout(function(){{scrollTo(p.x,p.y);}},50);}}}}catch(se){{}}
179 function showIndicator(){{
180 if(indicator)return;
181 indicator=document.createElement('div');
182 indicator.id='ssg-livereload';
183 indicator.textContent='Connecting\u2026';
184 indicator.style.cssText='position:fixed;bottom:8px;right:8px;z-index:99999;'
185 +'background:rgba(0,0,0,0.75);color:#fff;padding:6px 12px;border-radius:6px;'
186 +'font:13px/1 -apple-system,system-ui,sans-serif;pointer-events:none';
187 document.body.appendChild(indicator);
188 }}
189 function hideIndicator(){{
190 if(indicator){{indicator.remove();indicator=null;}}
191 }}
192 function showOverlay(msg){{
193 hideOverlay();
194 var d=document.createElement('div');
195 d.id='ssg-error-overlay';
196 d.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);color:#fff;font-family:monospace;font-size:14px;z-index:999999;padding:32px;overflow:auto;';
197 var c=document.createElement('div');
198 c.style.cssText='max-width:800px;margin:0 auto;';
199 var hdr=document.createElement('div');
200 hdr.style.cssText='display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;';
201 var title=document.createElement('span');
202 title.style.cssText='color:#ff6b6b;font-size:18px;font-weight:bold;';
203 title.textContent='Build Error';
204 var btn=document.createElement('button');
205 btn.textContent='\u2715';
206 btn.style.cssText='background:none;border:1px solid #666;color:#fff;padding:4px 12px;cursor:pointer;border-radius:4px;';
207 btn.addEventListener('click',hideOverlay);
208 hdr.appendChild(title);
209 hdr.appendChild(btn);
210 c.appendChild(hdr);
211 if(msg.file){{
212 var fp=document.createElement('div');
213 fp.style.cssText='color:#ffd93d;margin-bottom:8px;';
214 fp.textContent=msg.file+(msg.line?':'+msg.line:'');
215 c.appendChild(fp);
216 }}
217 var pre=document.createElement('pre');
218 pre.style.cssText='background:#1a1a2e;padding:16px;border-radius:8px;border-left:4px solid #ff6b6b;overflow-x:auto;white-space:pre-wrap;word-break:break-word;';
219 pre.textContent=msg.message;
220 c.appendChild(pre);
221 d.appendChild(c);
222 document.body.appendChild(d);
223 }}
224 function hideOverlay(){{var e=document.getElementById('ssg-error-overlay');if(e)e.remove();}}
225 function connect(){{
226 try{{
227 var ws=new WebSocket(url);
228 ws.onopen=function(){{delay=1000;hideIndicator();}};
229 ws.onmessage=function(e){{
230 if(e.data==='reload'){{hideOverlay();try{{sessionStorage.setItem('ssg-scroll',JSON.stringify({{x:scrollX,y:scrollY}}));}}catch(se){{}}
231 var doReload=function(){{location.reload();}};
232 // Issue #547: when the transitions client is active, wrap the
233 // reload in a view transition so structural HMR frames cross-fade.
234 if(typeof window.__ssgTransitionsReload==='function'){{
235 try{{window.__ssgTransitionsReload(doReload);}}catch(_){{doReload();}}
236 }}else{{doReload();}}
237 }}
238 try{{var msg=JSON.parse(e.data);
239 if(msg.type==='error'){{showOverlay(msg);}}
240 else if(msg.type==='clear-error'){{hideOverlay();}}
241 else if(msg.type==='css-reload'||msg.type==='hmr-css'){{
242 var links=document.querySelectorAll('link[rel=stylesheet]');
243 links.forEach(function(link){{
244 var href=link.getAttribute('href');
245 if(href){{link.setAttribute('href',href.split('?')[0]+'?v='+Date.now());}}
246 }});
247 }}
248 else if(msg.type==='hmr-html'){{
249 // Partial HTML reload (AC3 / AC4) — fetch the current page
250 // fresh and swap the <main> body so scroll position and
251 // any form state outside <main> are preserved. If the URL
252 // is not in the broadcast paths list we still do nothing
253 // (other tabs handle their own pages). If <head> needs an
254 // update the server sends 'reload' instead.
255 var here=location.pathname;
256 var hit=Array.isArray(msg.paths)&&msg.paths.some(function(p){{
257 return p===here||p===here.replace(/\/$/,'')||p+'/'===here;
258 }});
259 if(!hit){{return;}}
260 hideOverlay();
261 fetch(location.href,{{cache:'no-store'}}).then(function(r){{
262 return r.text();
263 }}).then(function(html){{
264 var parser=new DOMParser();
265 var doc=parser.parseFromString(html,'text/html');
266 var freshMain=doc.querySelector('main');
267 var liveMain=document.querySelector('main');
268 if(freshMain&&liveMain){{
269 liveMain.replaceWith(freshMain);
270 }}else if(doc.body&&document.body){{
271 document.body.innerHTML=doc.body.innerHTML;
272 }}else{{
273 location.reload();
274 }}
275 }}).catch(function(){{location.reload();}});
276 }}
277 else if(msg.type==='reload'){{
278 hideOverlay();
279 try{{sessionStorage.setItem('ssg-scroll',JSON.stringify({{x:scrollX,y:scrollY}}));}}catch(se){{}}
280 location.reload();
281 }}
282 }}catch(x){{}}
283 }};
284 ws.onclose=function(){{
285 var d=delay;
286 delay=Math.min(delay*2,maxDelay);
287 setTimeout(connect,d);
288 }};
289 ws.onerror=function(){{}};
290 }}catch(e){{}}
291 }}
292 // Only connect in development (localhost) and limit retries
293 // to avoid console error spam when the WS server is not running
294 if(location.hostname==='localhost'||location.hostname==='127.0.0.1'||location.hostname==='0.0.0.0'){{
295 if(document.readyState==='loading'){{
296 document.addEventListener('DOMContentLoaded',connect);
297 }}else{{
298 connect();
299 }}
300 }}
301}})();
302</script>
303"
304 )
305}
306
307#[must_use]
309#[allow(dead_code)]
310pub fn css_reload_message(css_path: &str) -> String {
311 serde_json::json!({
312 "type": "css-reload",
313 "file": css_path,
314 })
315 .to_string()
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use tempfile::tempdir;
322
323 fn make_html(body: &str) -> String {
324 format!(
325 "<html><head><title>Test</title></head>\
326 <body>{body}</body></html>"
327 )
328 }
329
330 #[test]
331 fn inject_adds_script() {
332 let tmp = tempdir().expect("tempdir");
333 let path = tmp.path().join("page.html");
334 fs::write(&path, make_html("<p>Hello</p>")).expect("write");
335
336 inject_livereload(&path, DEFAULT_PORT).expect("inject");
337
338 let result = fs::read_to_string(&path).expect("read");
339 assert!(result.contains(MARKER));
340 assert!(result.contains("WebSocket"));
341 assert!(result.contains("35729"));
342 assert!(result.contains("location.reload()"));
343 }
344
345 #[test]
346 fn inject_before_closing_body() {
347 let tmp = tempdir().expect("tempdir");
348 let path = tmp.path().join("page.html");
349 fs::write(&path, make_html("<p>Hi</p>")).expect("write");
350
351 inject_livereload(&path, DEFAULT_PORT).expect("inject");
352
353 let result = fs::read_to_string(&path).expect("read");
354 let script_pos = result.find(MARKER).unwrap();
355 let body_pos = result.rfind("</body>").unwrap();
356 assert!(script_pos < body_pos);
357 }
358
359 #[test]
360 fn inject_idempotent() {
361 let tmp = tempdir().expect("tempdir");
362 let path = tmp.path().join("page.html");
363 fs::write(&path, make_html("<p>Hi</p>")).expect("write");
364
365 inject_livereload(&path, DEFAULT_PORT).expect("inject");
366 let first = fs::read_to_string(&path).expect("read");
367
368 inject_livereload(&path, DEFAULT_PORT).expect("inject");
369 let second = fs::read_to_string(&path).expect("read");
370
371 assert_eq!(first, second);
372 }
373
374 #[test]
375 fn inject_custom_port() {
376 let tmp = tempdir().expect("tempdir");
377 let path = tmp.path().join("page.html");
378 fs::write(&path, make_html("<p>Hi</p>")).expect("write");
379
380 inject_livereload(&path, 9999).expect("inject");
381
382 let result = fs::read_to_string(&path).expect("read");
383 assert!(result.contains("9999"));
384 assert!(!result.contains("35729"));
385 }
386
387 #[test]
388 fn inject_no_body_tag() {
389 let tmp = tempdir().expect("tempdir");
390 let path = tmp.path().join("page.html");
391 fs::write(&path, "<html><p>No body tag</p></html>").expect("write");
392
393 inject_livereload(&path, DEFAULT_PORT).expect("inject");
394
395 let result = fs::read_to_string(&path).expect("read");
396 assert!(result.contains(MARKER));
397 }
398
399 #[test]
400 fn skip_non_html_files() {
401 let tmp = tempdir().expect("tempdir");
402 fs::write(tmp.path().join("style.css"), "body{}").expect("write");
403 fs::write(tmp.path().join("data.json"), "{}").expect("write");
404 fs::write(tmp.path().join("readme.txt"), "hello").expect("write");
405
406 let files = collect_html_files(tmp.path()).expect("collect");
407 assert!(files.is_empty());
408 }
409
410 #[test]
411 fn empty_directory() {
412 let tmp = tempdir().expect("tempdir");
413 let files = collect_html_files(tmp.path()).expect("collect");
414 assert!(files.is_empty());
415 }
416
417 #[test]
418 fn nonexistent_directory() {
419 let ctx = PluginContext::new(
420 Path::new("c"),
421 Path::new("b"),
422 Path::new("/nonexistent_dir_ssg_test"),
423 Path::new("t"),
424 );
425 let plugin = LiveReloadPlugin::new();
426 assert!(plugin.on_serve(&ctx).is_ok());
427 }
428
429 #[test]
430 fn plugin_name() {
431 assert_eq!(LiveReloadPlugin::new().name(), "livereload");
432 }
433
434 #[test]
435 fn plugin_registration() {
436 use crate::plugin::PluginManager;
437 let mut pm = PluginManager::new();
438 pm.register(LiveReloadPlugin::new());
439 assert_eq!(pm.names(), vec!["livereload"]);
440 }
441
442 #[test]
443 fn with_port_constructor() {
444 let plugin = LiveReloadPlugin::with_port(8080);
445 assert_eq!(plugin.port(), 8080);
446 }
447
448 #[test]
449 fn default_port_value() {
450 let plugin = LiveReloadPlugin::new();
451 assert_eq!(plugin.port(), 35729);
452 }
453
454 #[test]
455 fn default_trait_impl() {
456 let plugin = LiveReloadPlugin::default();
457 assert_eq!(plugin.port(), DEFAULT_PORT);
458 }
459
460 #[test]
461 fn on_serve_injects_all_html_files() {
462 let tmp = tempdir().expect("tempdir");
463 fs::write(tmp.path().join("index.html"), make_html("<p>Home</p>"))
464 .expect("write index");
465 fs::write(tmp.path().join("about.html"), make_html("<p>About</p>"))
466 .expect("write about");
467 fs::write(tmp.path().join("style.css"), "body{}").expect("write css");
468
469 let ctx = PluginContext::new(
470 Path::new("content"),
471 Path::new("build"),
472 tmp.path(),
473 Path::new("templates"),
474 );
475 LiveReloadPlugin::new().on_serve(&ctx).expect("on_serve");
476
477 let index = fs::read_to_string(tmp.path().join("index.html"))
478 .expect("read index");
479 let about = fs::read_to_string(tmp.path().join("about.html"))
480 .expect("read about");
481 let css =
482 fs::read_to_string(tmp.path().join("style.css")).expect("read css");
483
484 assert!(index.contains(MARKER));
485 assert!(about.contains(MARKER));
486 assert!(!css.contains(MARKER));
487 }
488
489 #[test]
490 fn script_contains_reconnect_backoff() {
491 let script = livereload_script(DEFAULT_PORT);
492 assert!(script.contains("delay*2"));
493 assert!(script.contains("maxDelay"));
494 assert!(script.contains("10000"));
495 }
496
497 #[test]
498 fn script_contains_connecting_indicator() {
499 let script = livereload_script(DEFAULT_PORT);
500 assert!(script.contains("Connecting"));
501 assert!(script.contains("showIndicator"));
502 assert!(script.contains("hideIndicator"));
503 assert!(script.contains("bottom"));
504 assert!(script.contains("right"));
505 }
506
507 #[test]
508 fn livereload_custom_port() {
509 let port: u16 = 44444;
511
512 let script = livereload_script(port);
514
515 assert!(script.contains("44444"));
517 assert!(!script.contains("35729"));
518 }
519
520 #[test]
521 fn livereload_plugin_no_html_files() {
522 let tmp = tempdir().expect("tempdir");
524 fs::write(tmp.path().join("style.css"), "body{}").expect("write");
525 fs::write(tmp.path().join("data.json"), "{}").expect("write");
526
527 let ctx = PluginContext::new(
528 Path::new("content"),
529 Path::new("build"),
530 tmp.path(),
531 Path::new("templates"),
532 );
533
534 let result = LiveReloadPlugin::new().on_serve(&ctx);
536
537 assert!(result.is_ok());
539 }
540
541 #[test]
542 fn livereload_plugin_idempotent() {
543 let tmp = tempdir().expect("tempdir");
545 let html_path = tmp.path().join("page.html");
546 fs::write(&html_path, make_html("<p>Hello</p>")).expect("write");
547
548 let ctx = PluginContext::new(
549 Path::new("content"),
550 Path::new("build"),
551 tmp.path(),
552 Path::new("templates"),
553 );
554
555 LiveReloadPlugin::new()
557 .on_serve(&ctx)
558 .expect("first on_serve");
559 let after_first = fs::read_to_string(&html_path).expect("read");
560
561 LiveReloadPlugin::new()
562 .on_serve(&ctx)
563 .expect("second on_serve");
564 let after_second = fs::read_to_string(&html_path).expect("read");
565
566 assert_eq!(after_first, after_second);
568 let script_count = after_second.matches("data-ssg-livereload").count();
571 assert_eq!(script_count, 1, "script tag should appear exactly once");
572 }
573
574 #[test]
575 fn livereload_script_contains_reconnect_logic() {
576 let script = livereload_script(DEFAULT_PORT);
578
579 assert!(script.contains("delay*2"), "should double the delay");
581 assert!(script.contains("maxDelay"), "should cap the delay");
582 assert!(script.contains("setTimeout"), "should schedule reconnect");
583 assert!(script.contains("connect"), "should call connect again");
584 }
585
586 #[test]
587 fn livereload_plugin_nonexistent_dir() {
588 let ctx = PluginContext::new(
590 Path::new("content"),
591 Path::new("build"),
592 Path::new("/absolutely/nonexistent/directory/for/test"),
593 Path::new("templates"),
594 );
595
596 let result = LiveReloadPlugin::new().on_serve(&ctx);
598
599 assert!(result.is_ok());
601 }
602
603 #[test]
604 #[cfg(unix)]
605 fn on_serve_propagates_inject_read_failure() {
606 use std::os::unix::fs::PermissionsExt;
607
608 let tmp = tempdir().expect("tempdir");
611 let locked = tmp.path().join("locked.html");
612 fs::write(&locked, make_html("<p>Hi</p>")).expect("write");
613 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
614 .expect("chmod");
615
616 let ctx = PluginContext::new(
617 Path::new("content"),
618 Path::new("build"),
619 tmp.path(),
620 Path::new("templates"),
621 );
622 let res = LiveReloadPlugin::new().on_serve(&ctx);
623
624 let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o644));
625 assert!(res.is_err(), "unreadable HTML file must fail on_serve");
626 }
627
628 #[test]
629 #[cfg(unix)]
630 fn inject_fails_on_readonly_file() {
631 use std::os::unix::fs::PermissionsExt;
632
633 let tmp = tempdir().expect("tempdir");
637 let path = tmp.path().join("frozen.html");
638 fs::write(&path, make_html("<p>Hi</p>")).expect("write");
639 fs::set_permissions(&path, fs::Permissions::from_mode(0o444))
640 .expect("chmod");
641
642 let res = inject_livereload(&path, DEFAULT_PORT);
643
644 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o644));
645 assert!(res.is_err(), "read-only HTML file must fail the write");
646 }
647
648 #[test]
649 fn test_script_contains_error_overlay() {
650 let script = livereload_script(DEFAULT_PORT);
651 assert!(
652 script.contains("showOverlay"),
653 "script must contain showOverlay function"
654 );
655 assert!(
656 script.contains("hideOverlay"),
657 "script must contain hideOverlay function"
658 );
659 assert!(
660 script.contains("ssg-error-overlay"),
661 "script must contain overlay element id"
662 );
663 }
664
665 #[test]
666 fn test_script_backward_compat() {
667 let script = livereload_script(DEFAULT_PORT);
668 assert!(
669 script.contains("'reload'"),
670 "script must still handle plain 'reload' messages"
671 );
672 }
673
674 #[test]
675 fn test_script_contains_css_reload() {
676 let script = livereload_script(DEFAULT_PORT);
677 assert!(
678 script.contains("css-reload"),
679 "script must contain css-reload handler"
680 );
681 }
682
683 #[test]
684 fn test_script_coordinates_with_view_transitions() {
685 let script = livereload_script(DEFAULT_PORT);
689 assert!(
690 script.contains("__ssgTransitionsReload"),
691 "reload path must consult window.__ssgTransitionsReload"
692 );
693 assert!(
694 script.contains("location.reload()"),
695 "reload path must still call location.reload() as the fallback"
696 );
697 }
698
699 #[test]
700 fn test_script_contains_scroll_preservation() {
701 let script = livereload_script(DEFAULT_PORT);
702 assert!(
703 script.contains("ssg-scroll"),
704 "script must contain scroll preservation key"
705 );
706 assert!(
707 script.contains("sessionStorage"),
708 "script must use sessionStorage for scroll"
709 );
710 }
711
712 #[test]
713 fn test_css_reload_message() {
714 let msg = css_reload_message("styles/main.css");
715 let parsed: serde_json::Value =
716 serde_json::from_str(&msg).expect("valid JSON");
717 assert_eq!(parsed["type"], "css-reload");
718 assert_eq!(parsed["file"], "styles/main.css");
719 }
720
721 #[test]
722 fn test_inject_fails_on_nonexistent_file() {
723 let tmp = tempdir().unwrap();
724 let path = tmp.path().join("nonexistent.html");
725 let res = inject_livereload(&path, DEFAULT_PORT);
726 assert!(res.is_err());
727 }
728
729 #[test]
730 fn test_inject_fails_on_directory() {
731 let tmp = tempdir().unwrap();
732 let res = inject_livereload(tmp.path(), DEFAULT_PORT);
733 assert!(res.is_err());
734 }
735
736 #[test]
737 #[cfg(unix)]
738 fn test_on_serve_collect_html_files_error() {
739 use std::os::unix::fs::PermissionsExt;
740 let tmp = tempdir().unwrap();
741 let unreadable = tmp.path().join("unreadable");
742 fs::create_dir(&unreadable).unwrap();
743 fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000))
744 .unwrap();
745
746 let ctx = PluginContext::new(
747 Path::new("content"),
748 Path::new("build"),
749 &unreadable,
750 Path::new("templates"),
751 );
752 let res = LiveReloadPlugin::new().on_serve(&ctx);
753
754 let _ =
755 fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o755));
756 assert!(res.is_err());
757 }
758}