1use anyhow::{Context, Result};
4use log::{debug, info, trace};
5use rayon::prelude::*;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use crate::config::{Config, PageDef};
11use crate::templates::Templates;
12use crate::tracker::{
13 AssetRef, BuildTracker, CachedDeps, SharedTracker, extract_html_asset_refs,
14 extract_markdown_asset_refs, resolve_url_to_source,
15};
16
17const CACHE_FILE: &str = ".rs-web-cache/deps.bin";
19
20pub struct Builder {
22 config: Config,
23 output_dir: PathBuf,
24 project_dir: PathBuf,
25 tracker: SharedTracker,
27 cached_deps: Option<CachedDeps>,
29 cached_global_data: Option<serde_json::Value>,
31 cached_pages: Option<Vec<PageDef>>,
33}
34
35impl Builder {
36 pub fn new(config: Config, output_dir: PathBuf, project_dir: PathBuf) -> Self {
37 let cache_path = project_dir.join(CACHE_FILE);
39 let cached_deps = CachedDeps::load(&cache_path);
40 if cached_deps.is_some() {
41 debug!("Loaded cached dependency info from {:?}", cache_path);
42 }
43
44 let tracker = config.tracker().clone();
46
47 Self {
48 config,
49 output_dir,
50 project_dir,
51 tracker,
52 cached_deps,
53 cached_global_data: None,
54 cached_pages: None,
55 }
56 }
57
58 pub fn new_with_tracker(project_dir: PathBuf, output_dir: PathBuf) -> Result<Self> {
60 let tracker = Arc::new(BuildTracker::new());
61 let config = Config::load_with_tracker(&project_dir, tracker.clone())?;
62
63 let cache_path = project_dir.join(CACHE_FILE);
65 let cached_deps = CachedDeps::load(&cache_path);
66 if cached_deps.is_some() {
67 debug!("Loaded cached dependency info from {:?}", cache_path);
68 }
69
70 Ok(Self {
71 config,
72 output_dir,
73 project_dir,
74 tracker,
75 cached_deps,
76 cached_global_data: None,
77 cached_pages: None,
78 })
79 }
80
81 fn resolve_path(&self, path: &str) -> PathBuf {
83 let p = Path::new(path);
84 if p.is_absolute() {
85 p.to_path_buf()
86 } else {
87 self.project_dir.join(path)
88 }
89 }
90
91 pub fn build(&mut self) -> Result<()> {
92 info!("Starting build");
93 debug!("Output directory: {:?}", self.output_dir);
94 debug!("Project directory: {:?}", self.project_dir);
95
96 trace!("Stage 1: Cleaning output directory");
98 self.clean()?;
99
100 trace!("Running before_build hook");
102 self.config.call_before_build()?;
103
104 trace!("Stage 2: Calling data() function");
106 let global_data = self.config.call_data()?;
107 debug!("Global data loaded");
108
109 trace!("Stage 3: Calling pages() function");
111 let pages = self.config.call_pages(&global_data)?;
112 debug!("Found {} pages to generate", pages.len());
113
114 self.cached_global_data = Some(global_data.clone());
116 self.cached_pages = Some(pages.clone());
117
118 trace!("Stage 5: Loading templates");
120 let templates = Templates::new(
121 &self.resolve_path(&self.config.paths.templates),
122 Some(self.tracker.clone()),
123 Some(self.config.asset_manifest.clone()),
124 )?;
125
126 trace!("Stage 6: Rendering {} pages", pages.len());
128 self.render_pages(&pages, &global_data, &templates)?;
129
130 info!("Build complete: {} pages generated", pages.len());
131 rs_print!("Generated {} pages", pages.len());
132
133 trace!("Running after_build hook");
134 self.config.call_after_build()?;
135
136 self.tracker.merge_all_threads();
138 self.save_cached_deps()?;
139
140 Ok(())
141 }
142
143 fn save_cached_deps(&self) -> Result<()> {
145 self.cleanup_stale_writes();
147
148 let cache_path = self.project_dir.join(CACHE_FILE);
149 let deps = CachedDeps::from_tracker(&self.tracker);
150 deps.save(&cache_path)
151 .with_context(|| format!("Failed to save dependency cache to {:?}", cache_path))?;
152 debug!(
153 "Saved dependency cache: {} reads, {} writes",
154 deps.reads.len(),
155 deps.writes.len()
156 );
157 Ok(())
158 }
159
160 fn cleanup_stale_writes(&self) {
162 let Some(old_deps) = &self.cached_deps else {
163 return;
164 };
165
166 let new_writes = self.tracker.get_writes();
167 let mut removed = 0;
168
169 for old_path in old_deps.writes.keys() {
170 if !old_path.starts_with(&self.output_dir) {
172 continue;
173 }
174
175 if !new_writes.contains_key(old_path) && old_path.exists() {
177 if let Err(e) = std::fs::remove_file(old_path) {
178 debug!("Failed to remove stale file {:?}: {}", old_path, e);
179 } else {
180 debug!("Removed stale file: {:?}", old_path);
181 removed += 1;
182 }
183 }
184 }
185
186 if removed > 0 {
187 debug!("Cleaned up {} stale files", removed);
188 }
189 }
190
191 fn cleanup_stale_files(
193 &self,
194 old_writes: &std::collections::HashMap<PathBuf, crate::tracker::FileState>,
195 new_writes: &std::collections::HashMap<PathBuf, crate::tracker::FileState>,
196 ) {
197 let mut removed = 0;
198
199 for old_path in old_writes.keys() {
200 if !old_path.starts_with(&self.output_dir) {
202 continue;
203 }
204
205 if !new_writes.contains_key(old_path) && old_path.exists() {
207 if let Err(e) = std::fs::remove_file(old_path) {
208 debug!("Failed to remove stale file {:?}: {}", old_path, e);
209 } else {
210 debug!("Removed stale file: {:?}", old_path);
211 removed += 1;
212 }
213 }
214 }
215
216 if removed > 0 {
217 rs_print!("Cleaned up {} stale files", removed);
218 }
219 }
220
221 pub fn get_changed_files(&self) -> Vec<PathBuf> {
223 match &self.cached_deps {
224 Some(cached) => self.tracker.get_changed_files(cached),
225 None => Vec::new(), }
227 }
228
229 pub fn needs_full_rebuild(&self) -> bool {
231 self.cached_deps.is_none()
232 }
233
234 pub fn is_tracked_file(&self, path: &Path) -> bool {
237 if let Some(ref cached) = self.cached_deps {
238 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
240 if cached.reads.contains_key(&path) {
242 return true;
243 }
244 if cached.asset_to_pages.contains_key(&path) {
246 return true;
247 }
248 false
249 } else {
250 true
252 }
253 }
254
255 pub fn has_tracked_changes(&self) -> bool {
257 if let Some(ref cached) = self.cached_deps {
258 !self.tracker.get_changed_files(cached).is_empty()
259 } else {
260 true }
262 }
263
264 fn clean(&self) -> Result<()> {
265 if self.output_dir.exists() {
266 debug!("Removing existing output directory: {:?}", self.output_dir);
267 fs::remove_dir_all(&self.output_dir).with_context(|| {
268 format!("Failed to clean output directory: {:?}", self.output_dir)
269 })?;
270 }
271 trace!("Creating output directories");
272 fs::create_dir_all(&self.output_dir)?;
273 fs::create_dir_all(self.output_dir.join("static"))?;
274 Ok(())
275 }
276
277 fn remove_stale_pages(&self, old_pages: &[PageDef], new_pages: &[PageDef]) -> Result<()> {
279 use std::collections::HashSet;
280
281 let new_paths: HashSet<&str> = new_pages.iter().map(|p| p.path.as_str()).collect();
283
284 for old_page in old_pages {
286 if !new_paths.contains(old_page.path.as_str()) {
287 let relative_path = old_page.path.trim_matches('/');
288
289 let has_extension = relative_path.contains('.')
291 && !relative_path.ends_with('/')
292 && relative_path
293 .rsplit('/')
294 .next()
295 .map(|s| s.contains('.'))
296 .unwrap_or(false);
297
298 let file_path = if has_extension {
299 self.output_dir.join(relative_path)
300 } else if relative_path.is_empty() {
301 self.output_dir.join("index.html")
302 } else {
303 self.output_dir.join(relative_path).join("index.html")
304 };
305
306 if file_path.exists() {
307 rs_print!(" Removed: {}", old_page.path);
308 fs::remove_file(&file_path)?;
309
310 if let Some(parent) = file_path.parent()
312 && parent != self.output_dir
313 && parent.read_dir()?.next().is_none()
314 {
315 let _ = fs::remove_dir(parent);
316 }
317 }
318 }
319 }
320
321 Ok(())
322 }
323
324 fn render_pages(
325 &self,
326 pages: &[PageDef],
327 global_data: &serde_json::Value,
328 templates: &Templates,
329 ) -> Result<()> {
330 pages
332 .par_iter()
333 .try_for_each(|page| self.render_single_page(page, global_data, templates))?;
334
335 Ok(())
336 }
337
338 fn render_single_page(
339 &self,
340 page: &PageDef,
341 global_data: &serde_json::Value,
342 templates: &Templates,
343 ) -> Result<()> {
344 trace!("Rendering page: {}", page.path);
345
346 let html_content = if let Some(ref markdown) = page.content {
348 Some(self.config.render_markdown(markdown)?)
349 } else {
350 page.html.clone()
351 };
352
353 let html = if page.template.is_none() {
355 html_content.unwrap_or_default()
356 } else {
357 templates.render_page(&self.config, page, global_data, html_content.as_deref())?
358 };
359
360 self.extract_and_record_asset_refs(page, &html);
362
363 let html = if page.minify {
365 minify_html(&html)
366 } else {
367 html
368 };
369
370 let relative_path = page.path.trim_matches('/');
372
373 let has_extension = relative_path.contains('.')
375 && !relative_path.ends_with('/')
376 && relative_path
377 .rsplit('/')
378 .next()
379 .map(|s| s.contains('.'))
380 .unwrap_or(false);
381
382 if has_extension {
383 let file_path = self.output_dir.join(relative_path);
385 if let Some(parent) = file_path.parent() {
386 fs::create_dir_all(parent)?;
387 }
388 fs::write(&file_path, &html)?;
389 self.tracker.record_write(file_path, html.as_bytes());
391 } else {
392 let page_dir = if relative_path.is_empty() {
394 self.output_dir.clone()
395 } else {
396 self.output_dir.join(relative_path)
397 };
398 fs::create_dir_all(&page_dir)?;
399 let file_path = page_dir.join("index.html");
400 fs::write(&file_path, &html)?;
401 self.tracker.record_write(file_path, html.as_bytes());
403 }
404
405 Ok(())
406 }
407
408 fn extract_and_record_asset_refs(&self, page: &PageDef, html: &str) {
410 let mut url_paths: Vec<String> = extract_html_asset_refs(html);
412
413 if let Some(ref markdown) = page.content {
415 let md_refs = extract_markdown_asset_refs(markdown);
416 url_paths.extend(md_refs);
417 }
418
419 if url_paths.is_empty() {
420 return;
421 }
422
423 let writes = self.tracker.get_writes();
425
426 let asset_refs: Vec<AssetRef> = url_paths
428 .into_iter()
429 .map(|url_path| {
430 let source_path =
431 resolve_url_to_source(&url_path, &self.output_dir, &writes, &self.project_dir);
432 AssetRef {
433 url_path,
434 source_path,
435 }
436 })
437 .collect();
438
439 let page_path = PathBuf::from(&page.path);
441 self.tracker.record_html_refs(page_path, asset_refs);
442 }
443
444 pub fn incremental_build(&mut self, changes: &crate::watch::ChangeSet) -> Result<()> {
447 debug!("Starting incremental build");
448 trace!("Change set: {:?}", changes);
449
450 if changes.full_rebuild {
452 return self.build();
453 }
454
455 let relevant_content: Vec<PathBuf> = changes
457 .content_files
458 .iter()
459 .filter(|p| {
460 let full_path = self.project_dir.join(p);
461 let is_tracked = self.is_tracked_file(&full_path);
462 if !is_tracked {
463 trace!("Skipping untracked content file: {:?}", p);
464 }
465 is_tracked
466 })
467 .map(|p| self.project_dir.join(p))
468 .collect();
469
470 let relevant_assets: Vec<PathBuf> = changes
472 .asset_files
473 .iter()
474 .filter(|p| {
475 let full_path = self.project_dir.join(p);
476 let is_tracked = self.is_tracked_file(&full_path);
477 if !is_tracked {
478 trace!("Skipping untracked asset file: {:?}", p);
479 }
480 is_tracked
481 })
482 .map(|p| self.project_dir.join(p))
483 .collect();
484
485 if !changes.content_files.is_empty() && relevant_content.is_empty() {
487 debug!(
488 "All {} content files were untracked, skipping",
489 changes.content_files.len()
490 );
491 }
492 if !changes.asset_files.is_empty() && relevant_assets.is_empty() {
493 debug!(
494 "All {} asset files were untracked, skipping",
495 changes.asset_files.len()
496 );
497 }
498
499 if !relevant_assets.is_empty() {
501 debug!(
502 "{} tracked assets changed (out of {} total)",
503 relevant_assets.len(),
504 changes.asset_files.len()
505 );
506 self.rebuild_assets_only(&relevant_assets)?;
507 }
508
509 if changes.rebuild_css {
511 self.rebuild_css_only()?;
512 }
513
514 if !relevant_content.is_empty() {
516 debug!(
517 "{} tracked content files changed (out of {} total)",
518 relevant_content.len(),
519 changes.content_files.len()
520 );
521 return self.rebuild_content_only(&relevant_content);
522 }
523
524 if changes.has_template_changes() {
526 for path in &changes.template_files {
527 rs_print!(" Changed: {}", path.display());
528 }
529 return self.rebuild_templates_only(&changes.template_files);
530 }
531
532 Ok(())
533 }
534
535 fn rebuild_content_only(&mut self, changed_paths: &[PathBuf]) -> Result<()> {
537 debug!(
538 "Content-only rebuild for {} changed files",
539 changed_paths.len()
540 );
541
542 for path in changed_paths {
544 if let Ok(rel) = path.strip_prefix(&self.project_dir) {
545 rs_print!(" Changed: {}", rel.display());
546 } else {
547 rs_print!(" Changed: {}", path.display());
548 }
549 }
550
551 let global_data = if let Some(cached) = self.cached_global_data.as_ref()
553 && self.config.has_update_data()
554 {
555 debug!("Using incremental update_data()");
556 let relative_paths: Vec<PathBuf> = changed_paths
558 .iter()
559 .filter_map(|p| {
560 p.strip_prefix(&self.project_dir)
561 .ok()
562 .map(|r| r.to_path_buf())
563 })
564 .collect();
565 self.config.call_update_data(cached, &relative_paths)?
566 } else {
567 debug!("Using full data() reload");
568 self.config.call_data()?
569 };
570
571 let pages = self.config.call_pages(&global_data)?;
572
573 if let Some(ref old_pages) = self.cached_pages {
575 self.remove_stale_pages(old_pages, &pages)?;
576 }
577
578 self.cached_global_data = Some(global_data.clone());
580 self.cached_pages = Some(pages.clone());
581
582 let templates = Templates::new(
584 &self.resolve_path(&self.config.paths.templates),
585 Some(self.tracker.clone()),
586 Some(self.config.asset_manifest.clone()),
587 )?;
588 self.render_pages(&pages, &global_data, &templates)?;
589
590 self.tracker.merge_all_threads();
592 self.save_cached_deps()?;
593
594 self.config.call_after_build()?;
595
596 rs_print!("Re-rendered {} pages (content changed)", pages.len());
597 Ok(())
598 }
599
600 fn rebuild_templates_only(
602 &mut self,
603 changed_template_files: &std::collections::HashSet<PathBuf>,
604 ) -> Result<()> {
605 let (global_data, all_pages) = match (&self.cached_global_data, &self.cached_pages) {
606 (Some(data), Some(pages)) => (data.clone(), pages.clone()),
607 _ => {
608 log::info!("No cached data available, performing full build");
610 return self.build();
611 }
612 };
613
614 let template_dir = self.resolve_path(&self.config.paths.templates);
616 let templates = Templates::new(
617 &template_dir,
618 Some(self.tracker.clone()),
619 Some(self.config.asset_manifest.clone()),
620 )?;
621 let deps = templates.deps();
622
623 let mut affected_templates = std::collections::HashSet::new();
625 for changed_path in changed_template_files {
626 if let Some(template_name) = deps.find_template_by_path(changed_path) {
628 let transitive = deps.get_affected_templates(template_name);
629 affected_templates.extend(transitive);
630 } else if let Ok(rel_path) = changed_path.strip_prefix(&template_dir) {
631 let template_name = rel_path.to_string_lossy().to_string();
633 let transitive = deps.get_affected_templates(&template_name);
634 affected_templates.extend(transitive);
635 }
636 }
637
638 debug!("Affected templates: {:?}", affected_templates);
639
640 let pages_to_rebuild: Vec<_> = all_pages
642 .iter()
643 .filter(|page| {
644 if let Some(ref template) = page.template {
645 affected_templates.contains(template)
646 } else {
647 false
648 }
649 })
650 .cloned()
651 .collect();
652
653 if pages_to_rebuild.is_empty() {
654 rs_print!("No pages affected by template changes");
655 return Ok(());
656 }
657
658 debug!(
659 "Template rebuild: {} of {} pages affected",
660 pages_to_rebuild.len(),
661 all_pages.len()
662 );
663
664 self.render_pages(&pages_to_rebuild, &global_data, &templates)?;
666
667 self.tracker.merge_all_threads();
669 self.save_cached_deps()?;
670
671 self.config.call_after_build()?;
672
673 rs_print!(
674 "Re-rendered {} of {} pages (templates changed)",
675 pages_to_rebuild.len(),
676 all_pages.len()
677 );
678 Ok(())
679 }
680
681 fn rebuild_css_only(&self) -> Result<()> {
683 rs_print!(" Changed: styles");
684 self.config.call_before_build()?;
685 rs_print!("Rebuilt CSS");
686
687 self.tracker.merge_all_threads();
689 self.save_cached_deps()?;
690
691 self.config.call_after_build()?;
692
693 Ok(())
694 }
695
696 fn rebuild_assets_only(&mut self, changed_paths: &[PathBuf]) -> Result<()> {
699 let old_writes = self.tracker.get_writes();
701 self.tracker.clear_writes();
702
703 for path in changed_paths {
704 if let Ok(rel) = path.strip_prefix(&self.project_dir) {
705 rs_print!(" Changed: {}", rel.display());
706 } else {
707 rs_print!(" Changed: {}", path.display());
708 }
709 }
710 self.config.call_before_build()?;
712
713 if let (Some(global_data), Some(pages)) = (&self.cached_global_data, &self.cached_pages) {
716 let templates = Templates::new(
717 &self.resolve_path(&self.config.paths.templates),
718 Some(self.tracker.clone()),
719 Some(self.config.asset_manifest.clone()),
720 )?;
721 self.render_pages(pages, global_data, &templates)?;
722 rs_print!(
723 "Rebuilt {} assets, re-rendered {} pages",
724 changed_paths.len(),
725 pages.len()
726 );
727 } else {
728 rs_print!("Rebuilt {} assets", changed_paths.len());
729 }
730
731 self.tracker.merge_all_threads();
733
734 let new_writes = self.tracker.get_writes();
736 self.cleanup_stale_files(&old_writes, &new_writes);
737
738 self.save_cached_deps()?;
739
740 self.config.call_after_build()?;
741
742 Ok(())
743 }
744
745 pub fn reload_config(&mut self) -> Result<()> {
747 debug!("Reloading config from {:?}", self.project_dir);
748 self.config = crate::config::Config::load(&self.project_dir)?;
749 self.cached_global_data = None;
751 self.cached_pages = None;
752 info!("Config reloaded successfully");
753 Ok(())
754 }
755
756 pub fn config(&self) -> &Config {
758 &self.config
759 }
760}
761
762fn minify_html(html: &str) -> String {
764 let html = minify_inline_js(html);
766
767 let cfg = minify_html::Cfg {
768 minify_js: false,
769 minify_css: true,
770 ..Default::default()
771 };
772 let minified = minify_html::minify(html.as_bytes(), &cfg);
773 String::from_utf8(minified).unwrap_or_else(|_| html.to_string())
774}
775
776fn minify_inline_js(html: &str) -> String {
778 use oxc_allocator::Allocator;
779 use oxc_codegen::{Codegen, CodegenOptions};
780 use oxc_minifier::{CompressOptions, MangleOptions, Minifier, MinifierOptions};
781 use oxc_parser::Parser;
782 use oxc_span::SourceType;
783 use regex::Regex;
784
785 let re = Regex::new(r"(?s)(<script(?:\s[^>]*)?>)(.*?)(</script>)").unwrap();
786
787 re.replace_all(html, |caps: ®ex::Captures| {
788 let open_tag = &caps[1];
789 let content = &caps[2];
790 let close_tag = &caps[3];
791
792 if open_tag.contains("src=") || content.trim().is_empty() {
794 return format!("{}{}{}", open_tag, content, close_tag);
795 }
796
797 let allocator = Allocator::default();
799 let source_type = SourceType::mjs();
800 let ret = Parser::new(&allocator, content, source_type).parse();
801
802 if !ret.errors.is_empty() {
803 return format!("{}{}{}", open_tag, content, close_tag);
805 }
806
807 let mut program = ret.program;
808 let options = MinifierOptions {
809 mangle: Some(MangleOptions::default()),
810 compress: Some(CompressOptions::default()),
811 };
812
813 Minifier::new(options).minify(&allocator, &mut program);
814 let minified = Codegen::new()
815 .with_options(CodegenOptions::minify())
816 .build(&program)
817 .code;
818
819 format!("{}{}{}", open_tag, minified, close_tag)
820 })
821 .to_string()
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 #[test]
829 fn test_minify_html_basic() {
830 let input = "<html> <body> <p>Hello</p> </body> </html>";
831 let result = minify_html(input);
832 assert!(result.len() <= input.len());
833 assert!(result.contains("Hello"));
834 }
835
836 #[test]
837 fn test_minify_html_preserves_pre() {
838 let input = "<pre> code with spaces </pre>";
839 let result = minify_html(input);
840 assert!(result.contains("code with spaces"));
842 }
843
844 #[test]
845 fn test_minify_inline_js_basic() {
846 let input = r#"<script>
847 function hello() {
848 console.log("hi");
849 }
850 </script>"#;
851 let result = minify_inline_js(input);
852 assert!(
853 !result.contains('\n') || result.matches('\n').count() < input.matches('\n').count()
854 );
855 assert!(result.contains("<script>"));
856 assert!(result.contains("</script>"));
857 }
858
859 #[test]
860 fn test_minify_inline_js_skips_external() {
861 let input = r#"<script src="/js/app.js"></script>"#;
862 let result = minify_inline_js(input);
863 assert_eq!(result, input);
864 }
865
866 #[test]
867 fn test_minify_inline_js_skips_empty() {
868 let input = "<script></script>";
869 let result = minify_inline_js(input);
870 assert_eq!(result, input);
871 }
872
873 #[test]
874 fn test_minify_inline_js_multiple_scripts() {
875 let input = r#"<script>console.log(1);</script><script>console.log(2);</script>"#;
877 let result = minify_inline_js(input);
878 assert!(
879 result.contains("console.log(1)") && result.contains("console.log(2)"),
880 "Result: {}",
881 result
882 );
883 }
884
885 #[test]
886 fn test_minify_inline_js_preserves_on_parse_error() {
887 let input = "<script>function { broken</script>";
888 let result = minify_inline_js(input);
889 assert!(result.contains("function { broken"));
891 }
892
893 #[test]
894 fn test_minify_inline_js_with_attributes() {
895 let input = r#"<script type="text/javascript">console.log(1);</script>"#;
897 let result = minify_inline_js(input);
898 assert!(result.contains(r#"type="text/javascript""#));
899 }
900
901 #[test]
902 fn test_minify_html_with_inline_js() {
903 let input = r#"<html><head><script>console.log(true);</script></head></html>"#;
905 let result = minify_html(input);
906 assert!(
908 result.contains("!0") || result.contains("true"),
909 "Result: {}",
910 result
911 );
912 }
913
914 #[test]
915 fn test_minify_html_css_minification() {
916 let input = r#"<style> body { color: red; } </style>"#;
917 let result = minify_html(input);
918 assert!(result.len() < input.len());
919 }
920}