Rust library to generate static websites

fix: bunch of fixes

+322 -142
+13 -19
crates/maudit-cli/src/dev.rs
··· 11 11 use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; 12 12 use quanta::Instant; 13 13 use server::WebSocketMessage; 14 - use std::{fs, path::{Path, PathBuf}}; 14 + use std::{ 15 + fs, 16 + path::{Path, PathBuf}, 17 + }; 15 18 use tokio::{ 16 19 signal, 17 20 sync::{broadcast, mpsc::channel}, ··· 21 24 22 25 use crate::dev::build::BuildManager; 23 26 24 - pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> { 27 + pub async fn start_dev_env( 28 + cwd: &str, 29 + host: bool, 30 + port: Option<u16>, 31 + ) -> Result<(), Box<dyn std::error::Error>> { 25 32 let start_time = Instant::now(); 26 33 info!(name: "dev", "Preparing dev environment…"); 27 34 ··· 166 173 // Normal rebuild - check if we need full recompilation or just rerun 167 174 let mut changed_paths: Vec<PathBuf> = events.iter() 168 175 .flat_map(|e| e.paths.iter().cloned()) 169 - .filter(|p| { 170 - // Only keep files with known asset extensions 171 - if let Some(ext) = p.extension() { 172 - let ext_str = ext.to_string_lossy().to_lowercase(); 173 - matches!(ext_str.as_str(), 174 - "rs" | "toml" | "css" | "js" | "ts" | "jsx" | "tsx" | 175 - "html" | "md" | "txt" | "json" | "yaml" | "yml" | 176 - "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" | 177 - "woff" | "woff2" | "ttf" | "eot" | "otf") 178 - } else { 179 - false 180 - } 181 - }) 182 176 .collect(); 183 - 177 + 184 178 // Deduplicate paths 185 179 changed_paths.sort(); 186 180 changed_paths.dedup(); 187 - 181 + 188 182 if changed_paths.is_empty() { 189 183 // No file changes, only directory changes - skip rebuild 190 184 continue; 191 185 } 192 - 186 + 193 187 let needs_recompile = build_manager_watcher.needs_recompile(&changed_paths).await; 194 - 188 + 195 189 if needs_recompile { 196 190 // Need to recompile - spawn in background so file watcher can continue 197 191 info!(name: "watch", "Files changed, rebuilding...");
+2 -6
crates/maudit-cli/src/dev/build.rs
··· 116 116 let child = Command::new(path) 117 117 .envs([ 118 118 ("MAUDIT_DEV", "true"), 119 + ("MAUDIT_QUIET", "true"), 119 120 ("MAUDIT_CHANGED_FILES", changed_files_json.as_str()), 120 121 ]) 121 122 .stdout(std::process::Stdio::piped()) ··· 130 131 format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev()); 131 132 132 133 if output.status.success() { 133 - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); 134 - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); 135 - println!("{}", stdout); 136 - if !stderr.is_empty() { 137 - println!("{}", stderr); 138 - } 139 134 info!(name: "build", "Binary rerun finished {}", formatted_elapsed_time); 140 135 update_status( 141 136 &self.websocket_tx, ··· 204 199 ]) 205 200 .envs([ 206 201 ("MAUDIT_DEV", "true"), 202 + ("MAUDIT_QUIET", "true"), 207 203 ("CARGO_TERM_COLOR", "always"), 208 204 ]) 209 205 .stdout(std::process::Stdio::piped())
+149 -19
crates/maudit-cli/src/dev/dep_tracker.rs
··· 57 57 let cargo_toml = current.join("Cargo.toml"); 58 58 if cargo_toml.exists() 59 59 && let Ok(content) = fs::read_to_string(&cargo_toml) 60 - && content.contains("[workspace]") { 61 - let workspace_target = current.join("target").join("debug"); 62 - if workspace_target.exists() { 63 - debug!("Using workspace target directory: {:?}", workspace_target); 64 - return Ok(workspace_target); 65 - } 66 - } 60 + && content.contains("[workspace]") 61 + { 62 + let workspace_target = current.join("target").join("debug"); 63 + if workspace_target.exists() { 64 + debug!("Using workspace target directory: {:?}", workspace_target); 65 + return Ok(workspace_target); 66 + } 67 + } 67 68 68 69 // Move up to parent directory 69 70 if !current.pop() { ··· 107 108 Ok(tracker) 108 109 } 109 110 111 + /// Parse space-separated paths from a string, handling escaped spaces 112 + /// In Make-style .d files, spaces in filenames are escaped with backslashes 113 + fn parse_paths(input: &str) -> Vec<PathBuf> { 114 + let mut paths = Vec::new(); 115 + let mut current_path = String::new(); 116 + let mut chars = input.chars().peekable(); 117 + 118 + while let Some(ch) = chars.next() { 119 + match ch { 120 + '\\' => { 121 + // Check if this is escaping a space or newline 122 + if let Some(&next_ch) = chars.peek() { 123 + if next_ch == ' ' { 124 + // Escaped space - add it to the current path 125 + current_path.push(' '); 126 + chars.next(); // consume the space 127 + } else if next_ch == '\n' || next_ch == '\r' { 128 + // Line continuation - skip the backslash and newline 129 + chars.next(); 130 + if next_ch == '\r' { 131 + // Handle \r\n 132 + if chars.peek() == Some(&'\n') { 133 + chars.next(); 134 + } 135 + } 136 + } else { 137 + // Not escaping space or newline, keep the backslash 138 + current_path.push('\\'); 139 + } 140 + } else { 141 + // Backslash at end of string 142 + current_path.push('\\'); 143 + } 144 + } 145 + ' ' | '\t' | '\n' | '\r' => { 146 + // Unescaped whitespace - end current path 147 + if !current_path.is_empty() { 148 + paths.push(PathBuf::from(current_path.clone())); 149 + current_path.clear(); 150 + } 151 + } 152 + _ => { 153 + current_path.push(ch); 154 + } 155 + } 156 + } 157 + 158 + // Don't forget the last path 159 + if !current_path.is_empty() { 160 + paths.push(PathBuf::from(current_path)); 161 + } 162 + 163 + paths 164 + } 165 + 110 166 /// Reload dependencies from the .d file 111 167 pub fn reload_dependencies(&mut self) -> Result<(), std::io::Error> { 112 168 let Some(d_file_path) = &self.d_file_path else { ··· 130 186 }; 131 187 132 188 // Dependencies are space-separated and may span multiple lines (with line continuations) 133 - let dep_paths: Vec<PathBuf> = deps 134 - .split_whitespace() 135 - .filter(|s| !s.is_empty() && *s != "\\") // Filter out line continuation characters 136 - .map(PathBuf::from) 137 - .collect(); 189 + // Spaces in filenames are escaped with backslashes 190 + let dep_paths = Self::parse_paths(deps); 138 191 139 192 // Clear old dependencies and load new ones with their modification times 140 193 self.dependencies.clear(); ··· 180 233 // Check if the file was modified after we last tracked it 181 234 if let Ok(metadata) = fs::metadata(changed_path) { 182 235 if let Ok(current_modified) = metadata.modified() 183 - && current_modified > *last_modified { 184 - debug!( 185 - "Dependency {:?} was modified, recompile needed", 186 - changed_path 187 - ); 188 - return true; 189 - } 236 + && current_modified > *last_modified 237 + { 238 + debug!( 239 + "Dependency {:?} was modified, recompile needed", 240 + changed_path 241 + ); 242 + return true; 243 + } 190 244 } else { 191 245 // File was deleted or can't be read, assume recompile is needed 192 246 debug!( ··· 243 297 244 298 // We won't have any dependencies because the files don't exist, 245 299 // but we've verified the parsing doesn't crash 300 + } 301 + 302 + #[test] 303 + fn test_parse_d_file_with_spaces() { 304 + let temp_dir = TempDir::new().unwrap(); 305 + let d_file_path = temp_dir.path().join("test_spaces.d"); 306 + 307 + // Create actual test files with spaces in names 308 + let dep_with_space = temp_dir.path().join("my file.rs"); 309 + fs::write(&dep_with_space, "// test").unwrap(); 310 + 311 + let normal_dep = temp_dir.path().join("normal.rs"); 312 + fs::write(&normal_dep, "// test").unwrap(); 313 + 314 + // Create a mock .d file with escaped spaces (Make format) 315 + let mut d_file = fs::File::create(&d_file_path).unwrap(); 316 + writeln!( 317 + d_file, 318 + "/path/to/target: {} {}", 319 + dep_with_space.to_str().unwrap().replace(' ', "\\ "), 320 + normal_dep.to_str().unwrap() 321 + ) 322 + .unwrap(); 323 + 324 + let mut tracker = DependencyTracker::new(); 325 + tracker.d_file_path = Some(d_file_path); 326 + 327 + // Load dependencies 328 + tracker.reload_dependencies().unwrap(); 329 + 330 + // Should have successfully parsed both files 331 + assert!(tracker.has_dependencies()); 332 + let deps = tracker.get_dependencies(); 333 + assert_eq!(deps.len(), 2); 334 + assert!( 335 + deps.iter() 336 + .any(|p| p.to_str().unwrap().contains("my file.rs")), 337 + "Should contain file with space" 338 + ); 339 + assert!( 340 + deps.iter() 341 + .any(|p| p.to_str().unwrap().contains("normal.rs")), 342 + "Should contain normal file" 343 + ); 344 + } 345 + 346 + #[test] 347 + fn test_parse_escaped_paths() { 348 + // Test basic space-separated paths 349 + let paths = DependencyTracker::parse_paths("a.rs b.rs c.rs"); 350 + assert_eq!(paths.len(), 3); 351 + assert_eq!(paths[0], PathBuf::from("a.rs")); 352 + assert_eq!(paths[1], PathBuf::from("b.rs")); 353 + assert_eq!(paths[2], PathBuf::from("c.rs")); 354 + 355 + // Test escaped spaces 356 + let paths = DependencyTracker::parse_paths("my\\ file.rs another.rs"); 357 + assert_eq!(paths.len(), 2); 358 + assert_eq!(paths[0], PathBuf::from("my file.rs")); 359 + assert_eq!(paths[1], PathBuf::from("another.rs")); 360 + 361 + // Test line continuation 362 + let paths = DependencyTracker::parse_paths("a.rs b.rs \\\nc.rs"); 363 + assert_eq!(paths.len(), 3); 364 + assert_eq!(paths[0], PathBuf::from("a.rs")); 365 + assert_eq!(paths[1], PathBuf::from("b.rs")); 366 + assert_eq!(paths[2], PathBuf::from("c.rs")); 367 + 368 + // Test multiple escaped spaces 369 + let paths = DependencyTracker::parse_paths("path/to/my\\ file\\ name.rs"); 370 + assert_eq!(paths.len(), 1); 371 + assert_eq!(paths[0], PathBuf::from("path/to/my file name.rs")); 372 + 373 + // Test mixed whitespace 374 + let paths = DependencyTracker::parse_paths("a.rs\tb.rs\nc.rs"); 375 + assert_eq!(paths.len(), 3); 246 376 } 247 377 }
+2 -6
crates/maudit-cli/src/logging.rs
··· 2 2 use std::{fmt, time::Duration}; 3 3 use tracing::{Event, Subscriber}; 4 4 use tracing_subscriber::{ 5 - fmt::{format, FmtContext, FormatEvent, FormatFields}, 5 + fmt::{FmtContext, FormatEvent, FormatFields, format}, 6 6 layer::SubscriberExt, 7 7 registry::LookupSpan, 8 8 util::SubscriberInitExt, ··· 135 135 tracing_subscriber::registry() 136 136 .with( 137 137 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { 138 - format!( 139 - "{}=info,maudit=info,tower_http=info", 140 - env!("CARGO_CRATE_NAME") 141 - ) 142 - .into() 138 + format!("{}=info,tower_http=info", env!("CARGO_CRATE_NAME")).into() 143 139 }), 144 140 ) 145 141 .with(tracing_formatter)
+97 -46
crates/maudit/src/build.rs
··· 14 14 self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin, 15 15 calculate_hash, image_cache::ImageCache, prefetch, 16 16 }, 17 - build::{images::process_image, options::PrefetchStrategy, state::{BuildState, RouteIdentifier}}, 17 + build::{ 18 + images::process_image, 19 + options::PrefetchStrategy, 20 + state::{BuildState, RouteIdentifier}, 21 + }, 18 22 content::ContentSources, 19 23 is_dev, 20 24 logging::print_title, ··· 48 52 Some(set) => set.contains(route_id), 49 53 None => true, // Full build 50 54 }; 51 - 55 + 52 56 if !result { 53 57 trace!(target: "build", "Skipping route {:?} (not in rebuild set)", route_id); 54 58 } 55 - 59 + 56 60 result 57 61 } 58 62 ··· 68 72 build_state.track_asset(canonical, route_id.clone()); 69 73 } 70 74 } 71 - 75 + 72 76 // Track scripts 73 77 for script in &route_assets.scripts { 74 78 if let Ok(canonical) = script.path().canonicalize() { 75 79 build_state.track_asset(canonical, route_id.clone()); 76 80 } 77 81 } 78 - 82 + 79 83 // Track styles 80 84 for style in &route_assets.styles { 81 85 if let Ok(canonical) = style.path().canonicalize() { ··· 123 127 debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some()); 124 128 125 129 // Determine if this is an incremental build 126 - let is_incremental = options.incremental && changed_files.is_some() && !build_state.asset_to_routes.is_empty(); 127 - 130 + let is_incremental = 131 + options.incremental && changed_files.is_some() && !build_state.asset_to_routes.is_empty(); 132 + 128 133 let routes_to_rebuild = if is_incremental { 129 134 let changed = changed_files.unwrap(); 130 135 info!(target: "build", "Incremental build: {} files changed", changed.len()); 131 136 info!(target: "build", "Changed files: {:?}", changed); 132 - 137 + 133 138 info!(target: "build", "Build state has {} asset mappings", build_state.asset_to_routes.len()); 134 - 139 + 135 140 let affected = build_state.get_affected_routes(changed); 136 141 info!(target: "build", "Rebuilding {} affected routes", affected.len()); 137 142 info!(target: "build", "Affected routes: {:?}", affected); 138 - 143 + 139 144 Some(affected) 140 145 } else { 141 146 if changed_files.is_some() { ··· 145 150 build_state.clear(); 146 151 None 147 152 }; 148 - 153 + 149 154 // Check if we should rebundle during incremental builds 150 155 // Rebundle if a changed file is either: 151 156 // 1. A direct bundler input (entry point) 152 - // 2. A transitive dependency tracked in asset_to_routes (JS/CSS/TS files) 157 + // 2. A transitive dependency tracked in asset_to_routes (any file the bundler processed) 153 158 let should_rebundle = if is_incremental && !build_state.bundler_inputs.is_empty() { 154 159 let changed = changed_files.unwrap(); 155 160 let should = changed.iter().any(|changed_file| { ··· 157 162 let is_bundler_input = build_state.bundler_inputs.iter().any(|bundler_input| { 158 163 if let (Ok(changed_canonical), Ok(bundler_canonical)) = ( 159 164 changed_file.canonicalize(), 160 - PathBuf::from(bundler_input).canonicalize() 165 + PathBuf::from(bundler_input).canonicalize(), 161 166 ) { 162 167 changed_canonical == bundler_canonical 163 168 } else { 164 169 false 165 170 } 166 171 }); 167 - 172 + 168 173 if is_bundler_input { 169 174 return true; 170 175 } 171 - 172 - // Check if it's a transitive dependency (JS/CSS/TS file in asset_to_routes) 173 - if let Some(ext) = changed_file.extension().and_then(|e| e.to_str()) { 174 - let is_bundleable = matches!(ext.to_lowercase().as_str(), "js" | "ts" | "jsx" | "tsx" | "css"); 175 - if is_bundleable 176 - && let Ok(canonical) = changed_file.canonicalize() { 177 - return build_state.asset_to_routes.contains_key(&canonical); 178 - } 176 + 177 + // Check if it's a transitive dependency tracked by the bundler 178 + // (JS/TS modules, CSS files, or assets like images/fonts referenced via url()) 179 + if let Ok(canonical) = changed_file.canonicalize() { 180 + return build_state.asset_to_routes.contains_key(&canonical); 179 181 } 180 - 182 + 181 183 false 182 184 }); 183 - 185 + 184 186 if should { 185 187 info!(target: "build", "Rebundling needed: changed file affects bundled assets"); 186 188 } else { 187 189 info!(target: "build", "Skipping bundler: no changed files affect bundled assets"); 188 190 } 189 - 191 + 190 192 should 191 193 } else { 192 194 // Not incremental or no previous bundler inputs ··· 320 322 // Static base route 321 323 if base_params.is_empty() { 322 324 let route_id = RouteIdentifier::base(base_path.clone(), None); 323 - 325 + 324 326 // Check if we need to rebuild this route 325 327 if should_rebuild_route(&route_id, &routes_to_rebuild) { 326 328 let mut route_assets = RouteAssets::with_default_assets( ··· 396 398 397 399 // Build all pages for this route 398 400 for page in pages { 399 - let route_id = RouteIdentifier::base( 400 - base_path.clone(), 401 - Some(page.0.0.clone()), 402 - ); 403 - 401 + let route_id = 402 + RouteIdentifier::base(base_path.clone(), Some(page.0.0.clone())); 403 + 404 404 // Check if we need to rebuild this specific page 405 405 if should_rebuild_route(&route_id, &routes_to_rebuild) { 406 406 let page_start = Instant::now(); ··· 458 458 459 459 if variant_params.is_empty() { 460 460 // Static variant 461 - let route_id = RouteIdentifier::variant( 462 - variant_id.clone(), 463 - variant_path.clone(), 464 - None, 465 - ); 466 - 461 + let route_id = 462 + RouteIdentifier::variant(variant_id.clone(), variant_path.clone(), None); 463 + 467 464 // Check if we need to rebuild this variant 468 465 if should_rebuild_route(&route_id, &routes_to_rebuild) { 469 466 let mut route_assets = RouteAssets::with_default_assets( ··· 475 472 476 473 let params = PageParams::default(); 477 474 let url = cached_route.variant_url(&params, &variant_id)?; 478 - let file_path = 479 - cached_route.variant_file_path(&params, &options.output_dir, &variant_id)?; 475 + let file_path = cached_route.variant_file_path( 476 + &params, 477 + &options.output_dir, 478 + &variant_id, 479 + )?; 480 480 481 481 let result = route.build(&mut PageContext::from_static_route( 482 482 content_sources, ··· 543 543 variant_path.clone(), 544 544 Some(page.0.0.clone()), 545 545 ); 546 - 546 + 547 547 // Check if we need to rebuild this specific variant page 548 548 if should_rebuild_route(&route_id, &routes_to_rebuild) { 549 549 let variant_page_start = Instant::now(); ··· 608 608 fs::create_dir_all(&route_assets_options.output_assets_dir)?; 609 609 } 610 610 611 - if !build_pages_styles.is_empty() || !build_pages_scripts.is_empty() || (is_incremental && should_rebundle) { 611 + if !build_pages_styles.is_empty() 612 + || !build_pages_scripts.is_empty() 613 + || (is_incremental && should_rebundle) 614 + { 612 615 let assets_start = Instant::now(); 613 616 print_title("generating assets"); 614 617 ··· 645 648 // to ensure we bundle all assets, not just from rebuilt routes 646 649 if is_incremental && !build_state.bundler_inputs.is_empty() { 647 650 debug!(target: "bundling", "Merging with {} previous bundler inputs", build_state.bundler_inputs.len()); 648 - 651 + 649 652 let current_imports: FxHashSet<String> = bundler_inputs 650 653 .iter() 651 654 .map(|input| input.import.clone()) 652 655 .collect(); 653 - 656 + 654 657 // Add previous inputs that aren't in the current set 655 658 for prev_input in &build_state.bundler_inputs { 656 659 if !current_imports.contains(prev_input) { ··· 687 690 688 691 if !bundler_inputs.is_empty() { 689 692 let mut module_types_hashmap = FxHashMap::default(); 693 + // Fonts 690 694 module_types_hashmap.insert("woff".to_string(), ModuleType::Asset); 691 695 module_types_hashmap.insert("woff2".to_string(), ModuleType::Asset); 696 + module_types_hashmap.insert("ttf".to_string(), ModuleType::Asset); 697 + module_types_hashmap.insert("otf".to_string(), ModuleType::Asset); 698 + module_types_hashmap.insert("eot".to_string(), ModuleType::Asset); 699 + // Images 700 + module_types_hashmap.insert("png".to_string(), ModuleType::Asset); 701 + module_types_hashmap.insert("jpg".to_string(), ModuleType::Asset); 702 + module_types_hashmap.insert("jpeg".to_string(), ModuleType::Asset); 703 + module_types_hashmap.insert("gif".to_string(), ModuleType::Asset); 704 + module_types_hashmap.insert("svg".to_string(), ModuleType::Asset); 705 + module_types_hashmap.insert("webp".to_string(), ModuleType::Asset); 706 + module_types_hashmap.insert("avif".to_string(), ModuleType::Asset); 707 + module_types_hashmap.insert("ico".to_string(), ModuleType::Asset); 692 708 693 709 let mut bundler = Bundler::with_plugins( 694 710 BundlerOptions { ··· 726 742 727 743 // Track transitive dependencies from bundler output 728 744 // For each chunk, map all its modules to the routes that use the entry point 745 + // For assets (images, fonts via CSS url()), map them to all routes using any entry point 729 746 if options.incremental { 747 + // First, collect all routes that use any bundler entry point 748 + let mut all_bundler_routes: FxHashSet<RouteIdentifier> = FxHashSet::default(); 749 + 730 750 for output in &result.assets { 731 751 if let Output::Chunk(chunk) = output { 732 752 // Get the entry point for this chunk ··· 734 754 // Try to find routes using this entry point 735 755 let entry_path = PathBuf::from(facade_module_id.as_str()); 736 756 let canonical_entry = entry_path.canonicalize().ok(); 737 - 757 + 738 758 // Look up routes for this entry point 739 759 let routes = canonical_entry 740 760 .as_ref() 741 761 .and_then(|p| build_state.asset_to_routes.get(p)) 742 762 .cloned(); 743 - 763 + 744 764 if let Some(routes) = routes { 765 + // Collect routes for asset tracking later 766 + all_bundler_routes.extend(routes.iter().cloned()); 767 + 745 768 // Register all modules in this chunk as dependencies for those routes 746 769 let mut transitive_count = 0; 747 770 for module_id in &chunk.module_ids { ··· 750 773 // Skip the entry point itself (already tracked) 751 774 if Some(&canonical_module) != canonical_entry.as_ref() { 752 775 for route in &routes { 753 - build_state.track_asset(canonical_module.clone(), route.clone()); 776 + build_state.track_asset( 777 + canonical_module.clone(), 778 + route.clone(), 779 + ); 754 780 } 755 781 transitive_count += 1; 756 782 } ··· 761 787 } 762 788 } 763 789 } 790 + } 791 + } 792 + 793 + // Now track Output::Asset items (images, fonts, etc. referenced via CSS url() or JS imports) 794 + // These are mapped to all routes that use any bundler entry point 795 + if !all_bundler_routes.is_empty() { 796 + let mut asset_count = 0; 797 + for output in &result.assets { 798 + if let Output::Asset(asset) = output { 799 + for original_file in &asset.original_file_names { 800 + let asset_path = PathBuf::from(original_file); 801 + if let Ok(canonical_asset) = asset_path.canonicalize() { 802 + for route in &all_bundler_routes { 803 + build_state.track_asset( 804 + canonical_asset.clone(), 805 + route.clone(), 806 + ); 807 + } 808 + asset_count += 1; 809 + } 810 + } 811 + } 812 + } 813 + if asset_count > 0 { 814 + debug!(target: "build", "Tracked {} bundler assets for {} routes", asset_count, all_bundler_routes.len()); 764 815 } 765 816 } 766 817 }
+10 -27
crates/maudit/src/build/options.rs
··· 235 235 236 236 /// Get the site name for cache directory purposes. 237 237 /// 238 - /// Tries to read the package name from Cargo.toml in the current directory, 238 + /// Uses the current executable's name (which matches the package/binary name), 239 239 /// falling back to the current directory name. 240 240 fn get_site_name() -> String { 241 - // Try to read package name from Cargo.toml 242 - if let Ok(cargo_toml) = fs::read_to_string("Cargo.toml") { 243 - // Simple parsing - look for name = "..." in [package] section 244 - let mut in_package = false; 245 - for line in cargo_toml.lines() { 246 - let trimmed = line.trim(); 247 - if trimmed == "[package]" { 248 - in_package = true; 249 - } else if trimmed.starts_with('[') { 250 - in_package = false; 251 - } else if in_package && trimmed.starts_with("name") { 252 - // Parse: name = "package-name" or name = 'package-name' 253 - if let Some(eq_pos) = trimmed.find('=') { 254 - let value = trimmed[eq_pos + 1..].trim(); 255 - let value = value.trim_matches('"').trim_matches('\''); 256 - if !value.is_empty() { 257 - return value.to_string(); 258 - } 259 - } 260 - } 261 - } 262 - } 263 - 264 - // Fallback to current directory name 265 - std::env::current_dir() 241 + // Get the binary name from the current executable 242 + std::env::current_exe() 266 243 .ok() 267 244 .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) 268 - .unwrap_or_else(|| "default".to_string()) 245 + .unwrap_or_else(|| { 246 + // Fallback to current directory name 247 + std::env::current_dir() 248 + .ok() 249 + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) 250 + .unwrap_or_else(|| "default".to_string()) 251 + }) 269 252 } 270 253 271 254 /// Find the target directory using multiple strategies
+1 -1
e2e/fixtures/hot-reload/src/main.rs
··· 1 - use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 2 3 3 mod pages { 4 4 mod index;
e2e/fixtures/incremental-build/src/assets/bg.png

This is a binary file and will not be displayed.

+6 -16
e2e/fixtures/incremental-build/src/assets/blog.css
··· 2 2 .blog-post { 3 3 margin: 20px; 4 4 } 5 - /* test */ 6 - /* test2 */ 7 - /* test5 */ 8 - /* test6 */ 9 - /* test */ 10 - /* test3 */ 11 - /* test5 */ 12 - /* changed */ 13 - /* change2 */ 14 - /* change */ 15 - /* change */ 16 - /* change2 */ 17 - /* change2 */ 18 - /* change2 */ 19 - /* change2 */ 20 - /* change2 */ 5 + 6 + /* Background image referenced via url() - tests CSS asset dependency tracking */ 7 + .blog-header { 8 + background-image: url('./bg.png'); 9 + background-size: cover; 10 + }
+1 -1
e2e/fixtures/incremental-build/src/main.rs
··· 1 - use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 2 3 3 mod pages; 4 4
+1 -1
e2e/fixtures/prefetch-prerender/src/main.rs
··· 1 - use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 2 3 3 mod pages { 4 4 mod about;
+40
e2e/tests/incremental-build.spec.ts
··· 124 124 stylesCss: resolve(fixturePath, "src", "assets", "styles.css"), 125 125 logoPng: resolve(fixturePath, "src", "assets", "logo.png"), 126 126 teamPng: resolve(fixturePath, "src", "assets", "team.png"), 127 + bgPng: resolve(fixturePath, "src", "assets", "bg.png"), 127 128 }; 128 129 129 130 // Output HTML paths ··· 145 146 originals.stylesCss = readFileSync(assets.stylesCss, "utf-8"); 146 147 originals.logoPng = readFileSync(assets.logoPng); // binary 147 148 originals.teamPng = readFileSync(assets.teamPng); // binary 149 + originals.bgPng = readFileSync(assets.bgPng); // binary 148 150 }); 149 151 150 152 test.afterAll(async () => { ··· 156 158 writeFileSync(assets.stylesCss, originals.stylesCss); 157 159 writeFileSync(assets.logoPng, originals.logoPng); 158 160 writeFileSync(assets.teamPng, originals.teamPng); 161 + writeFileSync(assets.bgPng, originals.bgPng); 159 162 }); 160 163 161 164 // ============================================================ ··· 367 370 const after = recordBuildIds(htmlPaths); 368 371 expect(after.index).toBe(before.index); 369 372 expect(after.about).not.toBe(before.about); 373 + expect(after.blog).not.toBe(before.blog); 374 + }); 375 + 376 + // ============================================================ 377 + // TEST 7: CSS url() asset dependency (bg.png via blog.css → /blog) 378 + // ============================================================ 379 + test("CSS url() asset change triggers rebundling and rebuilds affected routes", async ({ devServer }) => { 380 + let testCounter = 0; 381 + 382 + async function triggerChange(suffix: string) { 383 + testCounter++; 384 + devServer.clearLogs(); 385 + // Modify bg.png - this is referenced via url() in blog.css 386 + // Changing it should trigger rebundling and rebuild /blog 387 + const modified = Buffer.concat([ 388 + originals.bgPng as Buffer, 389 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`) 390 + ]); 391 + writeFileSync(assets.bgPng, modified); 392 + return await waitForBuildComplete(devServer, 30000); 393 + } 394 + 395 + await setupIncrementalState(devServer, triggerChange); 396 + 397 + const before = recordBuildIds(htmlPaths); 398 + expect(before.blog).not.toBeNull(); 399 + 400 + await new Promise(resolve => setTimeout(resolve, 500)); 401 + 402 + const logs = await triggerChange("final"); 403 + 404 + // Verify incremental build triggered 405 + expect(isIncrementalBuild(logs)).toBe(true); 406 + 407 + // Blog should be rebuilt (uses blog.css which references bg.png via url()) 408 + // The bundler should have been re-run to update the hashed asset reference 409 + const after = recordBuildIds(htmlPaths); 370 410 expect(after.blog).not.toBe(before.blog); 371 411 }); 372 412 });