Rust library to generate static websites

fix: some assets error handling and tests

+453 -74
+16 -8
Cargo.lock
··· 1662 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 1663 1664 [[package]] 1665 name = "flate2" 1666 version = "1.1.8" 1667 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3845 version = "0.1.1" 3846 source = "registry+https://github.com/rust-lang/crates.io-index" 3847 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3848 - 3849 - [[package]] 3850 - name = "prefetch-prerender" 3851 - version = "0.1.0" 3852 - dependencies = [ 3853 - "maud", 3854 - "maudit", 3855 - ] 3856 3857 [[package]] 3858 name = "proc-macro-crate"
··· 1662 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 1663 1664 [[package]] 1665 + name = "fixtures-hot-reload" 1666 + version = "0.1.0" 1667 + dependencies = [ 1668 + "maud", 1669 + "maudit", 1670 + ] 1671 + 1672 + [[package]] 1673 + name = "fixtures-prefetch-prerender" 1674 + version = "0.1.0" 1675 + dependencies = [ 1676 + "maud", 1677 + "maudit", 1678 + ] 1679 + 1680 + [[package]] 1681 name = "flate2" 1682 version = "1.1.8" 1683 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3861 version = "0.1.1" 3862 source = "registry+https://github.com/rust-lang/crates.io-index" 3863 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3864 3865 [[package]] 3866 name = "proc-macro-crate"
+154 -36
crates/maudit/src/assets.rs
··· 432 } 433 434 fn make_filename(path: &Path, hash: &String, extension: Option<&str>) -> PathBuf { 435 - let file_stem = path.file_stem().unwrap(); 436 - let sanitized_stem = sanitize_filename::default_sanitize_file_name(file_stem.to_str().unwrap()); 437 438 let mut filename = PathBuf::new(); 439 filename.push(format!("{}.{}", sanitized_stem, hash)); ··· 533 #[cfg(test)] 534 mod tests { 535 use super::*; 536 - use std::env; 537 538 - fn setup_temp_dir() -> PathBuf { 539 - // Create a temporary directory and test files 540 - let temp_dir = env::temp_dir().join("maudit_test"); 541 - std::fs::create_dir_all(&temp_dir).unwrap(); 542 543 - std::fs::write(temp_dir.join("style.css"), "body { background: red; }").unwrap(); 544 - std::fs::write(temp_dir.join("script.js"), "console.log('Hello, world!');").unwrap(); 545 - std::fs::write(temp_dir.join("image.png"), b"").unwrap(); 546 temp_dir 547 } 548 ··· 550 fn test_add_style() { 551 let temp_dir = setup_temp_dir(); 552 let mut page_assets = RouteAssets::default(); 553 - page_assets.add_style(temp_dir.join("style.css")).unwrap(); 554 555 assert!(page_assets.styles.len() == 1); 556 } ··· 561 let mut page_assets = RouteAssets::default(); 562 563 page_assets 564 - .include_style(temp_dir.join("style.css")) 565 .unwrap(); 566 567 assert!(page_assets.styles.len() == 1); ··· 573 let temp_dir = setup_temp_dir(); 574 let mut page_assets = RouteAssets::default(); 575 576 - page_assets.add_script(temp_dir.join("script.js")).unwrap(); 577 assert!(page_assets.scripts.len() == 1); 578 } 579 ··· 583 let mut page_assets = RouteAssets::default(); 584 585 page_assets 586 - .include_script(temp_dir.join("script.js")) 587 .unwrap(); 588 589 assert!(page_assets.scripts.len() == 1); ··· 595 let temp_dir = setup_temp_dir(); 596 let mut page_assets = RouteAssets::default(); 597 598 - page_assets.add_image(temp_dir.join("image.png")).unwrap(); 599 assert!(page_assets.images.len() == 1); 600 } 601 ··· 604 let temp_dir = setup_temp_dir(); 605 let mut page_assets = RouteAssets::default(); 606 607 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 608 assert_eq!(image.url().chars().next(), Some('/')); 609 610 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 611 assert_eq!(script.url().chars().next(), Some('/')); 612 613 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 614 assert_eq!(style.url().chars().next(), Some('/')); 615 } 616 ··· 619 let temp_dir = setup_temp_dir(); 620 let mut page_assets = RouteAssets::default(); 621 622 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 623 assert!(image.url().contains(&image.hash)); 624 625 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 626 assert!(script.url().contains(&script.hash)); 627 628 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 629 assert!(style.url().contains(&style.hash)); 630 } 631 ··· 634 let temp_dir = setup_temp_dir(); 635 let mut page_assets = RouteAssets::default(); 636 637 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 638 assert!(image.build_path().to_string_lossy().contains(&image.hash)); 639 640 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 641 assert!(script.build_path().to_string_lossy().contains(&script.hash)); 642 643 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 644 assert!(style.build_path().to_string_lossy().contains(&style.hash)); 645 } 646 647 #[test] 648 fn test_image_hash_different_options() { 649 let temp_dir = setup_temp_dir(); 650 - let image_path = temp_dir.join("image.png"); 651 652 // Create a simple test PNG (1x1 transparent pixel) 653 let png_data = [ ··· 659 ]; 660 std::fs::write(&image_path, png_data).unwrap(); 661 662 - let mut page_assets = RouteAssets::default(); 663 664 // Test that different options produce different hashes 665 let image_default = page_assets.add_image(&image_path).unwrap(); ··· 716 #[test] 717 fn test_image_hash_same_options() { 718 let temp_dir = setup_temp_dir(); 719 - let image_path = temp_dir.join("image.png"); 720 721 // Create a simple test PNG (1x1 transparent pixel) 722 let png_data = [ ··· 728 ]; 729 std::fs::write(&image_path, png_data).unwrap(); 730 731 - let mut page_assets = RouteAssets::default(); 732 733 // Same options should produce same hash 734 let image1 = page_assets ··· 762 #[test] 763 fn test_style_hash_different_options() { 764 let temp_dir = setup_temp_dir(); 765 - let style_path = temp_dir.join("style.css"); 766 767 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 768 769 // Test that different tailwind options produce different hashes 770 let style_default = page_assets.add_style(&style_path).unwrap(); ··· 784 785 // Create two identical files with different paths 786 let content = "body { background: blue; }"; 787 - let style1_path = temp_dir.join("style1.css"); 788 - let style2_path = temp_dir.join("style2.css"); 789 790 std::fs::write(&style1_path, content).unwrap(); 791 std::fs::write(&style2_path, content).unwrap(); 792 793 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 794 795 let style1 = page_assets.add_style(&style1_path).unwrap(); 796 let style2 = page_assets.add_style(&style2_path).unwrap(); ··· 804 #[test] 805 fn test_hash_includes_content() { 806 let temp_dir = setup_temp_dir(); 807 - let style_path = temp_dir.join("dynamic_style.css"); 808 809 - let assets_options = RouteAssetsOptions::default(); 810 - let mut page_assets = RouteAssets::new(&assets_options, None); 811 812 // Write first content and get hash 813 std::fs::write(&style_path, "body { background: red; }").unwrap(); ··· 823 hash1, hash2, 824 "Different content should produce different hashes" 825 ); 826 } 827 }
··· 432 } 433 434 fn make_filename(path: &Path, hash: &String, extension: Option<&str>) -> PathBuf { 435 + let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("asset"); 436 + 437 + let sanitized_stem = sanitize_filename::default_sanitize_file_name(file_stem); 438 439 let mut filename = PathBuf::new(); 440 filename.push(format!("{}.{}", sanitized_stem, hash)); ··· 534 #[cfg(test)] 535 mod tests { 536 use super::*; 537 538 + fn setup_temp_dir() -> tempfile::TempDir { 539 + let temp_dir = tempfile::tempdir().unwrap(); 540 541 + std::fs::write( 542 + temp_dir.path().join("style.css"), 543 + "body { background: red; }", 544 + ) 545 + .unwrap(); 546 + std::fs::write( 547 + temp_dir.path().join("script.js"), 548 + "console.log('Hello, world!');", 549 + ) 550 + .unwrap(); 551 + std::fs::write(temp_dir.path().join("image.png"), b"").unwrap(); 552 temp_dir 553 } 554 ··· 556 fn test_add_style() { 557 let temp_dir = setup_temp_dir(); 558 let mut page_assets = RouteAssets::default(); 559 + page_assets 560 + .add_style(temp_dir.path().join("style.css")) 561 + .unwrap(); 562 563 assert!(page_assets.styles.len() == 1); 564 } ··· 569 let mut page_assets = RouteAssets::default(); 570 571 page_assets 572 + .include_style(temp_dir.path().join("style.css")) 573 .unwrap(); 574 575 assert!(page_assets.styles.len() == 1); ··· 581 let temp_dir = setup_temp_dir(); 582 let mut page_assets = RouteAssets::default(); 583 584 + page_assets 585 + .add_script(temp_dir.path().join("script.js")) 586 + .unwrap(); 587 assert!(page_assets.scripts.len() == 1); 588 } 589 ··· 593 let mut page_assets = RouteAssets::default(); 594 595 page_assets 596 + .include_script(temp_dir.path().join("script.js")) 597 .unwrap(); 598 599 assert!(page_assets.scripts.len() == 1); ··· 605 let temp_dir = setup_temp_dir(); 606 let mut page_assets = RouteAssets::default(); 607 608 + page_assets 609 + .add_image(temp_dir.path().join("image.png")) 610 + .unwrap(); 611 assert!(page_assets.images.len() == 1); 612 } 613 ··· 616 let temp_dir = setup_temp_dir(); 617 let mut page_assets = RouteAssets::default(); 618 619 + let image = page_assets 620 + .add_image(temp_dir.path().join("image.png")) 621 + .unwrap(); 622 assert_eq!(image.url().chars().next(), Some('/')); 623 624 + let script = page_assets 625 + .add_script(temp_dir.path().join("script.js")) 626 + .unwrap(); 627 assert_eq!(script.url().chars().next(), Some('/')); 628 629 + let style = page_assets 630 + .add_style(temp_dir.path().join("style.css")) 631 + .unwrap(); 632 assert_eq!(style.url().chars().next(), Some('/')); 633 } 634 ··· 637 let temp_dir = setup_temp_dir(); 638 let mut page_assets = RouteAssets::default(); 639 640 + let image = page_assets 641 + .add_image(temp_dir.path().join("image.png")) 642 + .unwrap(); 643 assert!(image.url().contains(&image.hash)); 644 645 + let script = page_assets 646 + .add_script(temp_dir.path().join("script.js")) 647 + .unwrap(); 648 assert!(script.url().contains(&script.hash)); 649 650 + let style = page_assets 651 + .add_style(temp_dir.path().join("style.css")) 652 + .unwrap(); 653 assert!(style.url().contains(&style.hash)); 654 } 655 ··· 658 let temp_dir = setup_temp_dir(); 659 let mut page_assets = RouteAssets::default(); 660 661 + let image = page_assets 662 + .add_image(temp_dir.path().join("image.png")) 663 + .unwrap(); 664 assert!(image.build_path().to_string_lossy().contains(&image.hash)); 665 666 + let script = page_assets 667 + .add_script(temp_dir.path().join("script.js")) 668 + .unwrap(); 669 assert!(script.build_path().to_string_lossy().contains(&script.hash)); 670 671 + let style = page_assets 672 + .add_style(temp_dir.path().join("style.css")) 673 + .unwrap(); 674 assert!(style.build_path().to_string_lossy().contains(&style.hash)); 675 } 676 677 #[test] 678 fn test_image_hash_different_options() { 679 let temp_dir = setup_temp_dir(); 680 + let image_path = temp_dir.path().join("image.png"); 681 682 // Create a simple test PNG (1x1 transparent pixel) 683 let png_data = [ ··· 689 ]; 690 std::fs::write(&image_path, png_data).unwrap(); 691 692 + let mut page_assets = RouteAssets::new( 693 + &RouteAssetsOptions { 694 + hashing_strategy: AssetHashingStrategy::Precise, 695 + ..Default::default() 696 + }, 697 + None, 698 + ); 699 700 // Test that different options produce different hashes 701 let image_default = page_assets.add_image(&image_path).unwrap(); ··· 752 #[test] 753 fn test_image_hash_same_options() { 754 let temp_dir = setup_temp_dir(); 755 + let image_path = temp_dir.path().join("image.png"); 756 757 // Create a simple test PNG (1x1 transparent pixel) 758 let png_data = [ ··· 764 ]; 765 std::fs::write(&image_path, png_data).unwrap(); 766 767 + let mut page_assets = RouteAssets::new( 768 + &RouteAssetsOptions { 769 + hashing_strategy: AssetHashingStrategy::Precise, 770 + ..Default::default() 771 + }, 772 + None, 773 + ); 774 775 // Same options should produce same hash 776 let image1 = page_assets ··· 804 #[test] 805 fn test_style_hash_different_options() { 806 let temp_dir = setup_temp_dir(); 807 + let style_path = temp_dir.path().join("style.css"); 808 809 + let mut page_assets = RouteAssets::new( 810 + &RouteAssetsOptions { 811 + hashing_strategy: AssetHashingStrategy::Precise, 812 + ..Default::default() 813 + }, 814 + None, 815 + ); 816 817 // Test that different tailwind options produce different hashes 818 let style_default = page_assets.add_style(&style_path).unwrap(); ··· 832 833 // Create two identical files with different paths 834 let content = "body { background: blue; }"; 835 + let style1_path = temp_dir.path().join("style1.css"); 836 + let style2_path = temp_dir.path().join("style2.css"); 837 838 std::fs::write(&style1_path, content).unwrap(); 839 std::fs::write(&style2_path, content).unwrap(); 840 841 + let mut page_assets = RouteAssets::new( 842 + &RouteAssetsOptions { 843 + hashing_strategy: AssetHashingStrategy::Precise, 844 + ..Default::default() 845 + }, 846 + None, 847 + ); 848 849 let style1 = page_assets.add_style(&style1_path).unwrap(); 850 let style2 = page_assets.add_style(&style2_path).unwrap(); ··· 858 #[test] 859 fn test_hash_includes_content() { 860 let temp_dir = setup_temp_dir(); 861 + let style_path = temp_dir.path().join("dynamic_style.css"); 862 863 + let mut page_assets = RouteAssets::new( 864 + &RouteAssetsOptions { 865 + hashing_strategy: AssetHashingStrategy::Precise, 866 + ..Default::default() 867 + }, 868 + None, 869 + ); 870 871 // Write first content and get hash 872 std::fs::write(&style_path, "body { background: red; }").unwrap(); ··· 882 hash1, hash2, 883 "Different content should produce different hashes" 884 ); 885 + } 886 + 887 + #[test] 888 + fn test_make_filename_normal_path() { 889 + let path = PathBuf::from("/foo/bar/test.png"); 890 + let hash = "abc12".to_string(); 891 + 892 + let filename = make_filename(&path, &hash, Some("png")); 893 + 894 + // Format is: stem.hash with extension hash.ext 895 + assert_eq!(filename.to_string_lossy(), "test.abc12.png"); 896 + } 897 + 898 + #[test] 899 + fn test_make_filename_no_extension() { 900 + let path = PathBuf::from("/foo/bar/test"); 901 + let hash = "abc12".to_string(); 902 + 903 + let filename = make_filename(&path, &hash, None); 904 + 905 + assert_eq!(filename.to_string_lossy(), "test.abc12"); 906 + } 907 + 908 + #[test] 909 + fn test_make_filename_fallback_for_root_path() { 910 + // Root path has no file stem 911 + let path = PathBuf::from("/"); 912 + let hash = "abc12".to_string(); 913 + 914 + let filename = make_filename(&path, &hash, Some("css")); 915 + 916 + // Should fallback to "asset" 917 + assert_eq!(filename.to_string_lossy(), "asset.abc12.css"); 918 + } 919 + 920 + #[test] 921 + fn test_make_filename_fallback_for_dotdot_path() { 922 + // Path ending with ".." has no file stem 923 + let path = PathBuf::from("/foo/.."); 924 + let hash = "xyz99".to_string(); 925 + 926 + let filename = make_filename(&path, &hash, Some("js")); 927 + 928 + // Should fallback to "asset" 929 + assert_eq!(filename.to_string_lossy(), "asset.xyz99.js"); 930 + } 931 + 932 + #[test] 933 + fn test_make_filename_with_special_characters() { 934 + // Test that special characters get sanitized 935 + let path = PathBuf::from("/foo/test:file*.txt"); 936 + let hash = "def45".to_string(); 937 + 938 + let filename = make_filename(&path, &hash, Some("txt")); 939 + 940 + // Special characters should be replaced with underscores 941 + let result = filename.to_string_lossy(); 942 + assert!(result.contains("test_file_")); 943 + assert!(result.ends_with(".def45.txt")); 944 } 945 }
+99 -5
crates/maudit/src/assets/image.rs
··· 154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 155 /// 156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 157 - pub fn placeholder(&self) -> ImagePlaceholder { 158 get_placeholder(&self.path, self.cache.as_ref()) 159 } 160 ··· 258 } 259 } 260 261 - fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> ImagePlaceholder { 262 // Check cache first if provided 263 if let Some(cache) = cache 264 && let Some(cached) = cache.get_placeholder(path) 265 { 266 debug!("Using cached placeholder for {}", path.display()); 267 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 268 - return ImagePlaceholder::new(cached.thumbhash, thumbhash_base64); 269 } 270 271 let total_start = Instant::now(); 272 273 let load_start = Instant::now(); 274 - let image = image::open(path).ok().unwrap(); 275 let (width, height) = image.dimensions(); 276 let (width, height) = (width as usize, height as usize); 277 debug!( ··· 329 cache.cache_placeholder(path, thumb_hash.clone()); 330 } 331 332 - ImagePlaceholder::new(thumb_hash, thumbhash_base64) 333 } 334 335 /// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234 ··· 516 ).into() 517 } 518 }
··· 154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 155 /// 156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 157 + /// 158 + /// Returns an error if the image cannot be loaded. 159 + pub fn placeholder(&self) -> Result<ImagePlaceholder, crate::errors::AssetError> { 160 get_placeholder(&self.path, self.cache.as_ref()) 161 } 162 ··· 260 } 261 } 262 263 + fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> Result<ImagePlaceholder, crate::errors::AssetError> { 264 // Check cache first if provided 265 if let Some(cache) = cache 266 && let Some(cached) = cache.get_placeholder(path) 267 { 268 debug!("Using cached placeholder for {}", path.display()); 269 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 270 + return Ok(ImagePlaceholder::new(cached.thumbhash, thumbhash_base64)); 271 } 272 273 let total_start = Instant::now(); 274 275 let load_start = Instant::now(); 276 + let image = image::open(path).map_err(|e| crate::errors::AssetError::ImageLoadFailed { 277 + path: path.clone(), 278 + source: e, 279 + })?; 280 let (width, height) = image.dimensions(); 281 let (width, height) = (width as usize, height as usize); 282 debug!( ··· 334 cache.cache_placeholder(path, thumb_hash.clone()); 335 } 336 337 + Ok(ImagePlaceholder::new(thumb_hash, thumbhash_base64)) 338 } 339 340 /// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234 ··· 521 ).into() 522 } 523 } 524 + 525 + #[cfg(test)] 526 + mod tests { 527 + use super::*; 528 + use std::path::PathBuf; 529 + 530 + fn setup_unique_temp_dir() -> tempfile::TempDir { 531 + // Create a unique temporary directory that's automatically cleaned up 532 + tempfile::tempdir().unwrap() 533 + } 534 + 535 + #[test] 536 + fn test_placeholder_with_missing_file() { 537 + let nonexistent_path = PathBuf::from("/this/file/does/not/exist.png"); 538 + 539 + let result = get_placeholder(&nonexistent_path, None); 540 + 541 + // Should return an error, not panic 542 + assert!(result.is_err()); 543 + 544 + if let Err(crate::errors::AssetError::ImageLoadFailed { path, .. }) = result { 545 + assert_eq!(path, nonexistent_path); 546 + } else { 547 + panic!("Expected ImageLoadFailed error"); 548 + } 549 + } 550 + 551 + #[test] 552 + fn test_placeholder_with_invalid_image_data() { 553 + let temp_dir = setup_unique_temp_dir(); 554 + 555 + // Create a file with invalid image data 556 + let invalid_image_path = temp_dir.path().join("invalid.png"); 557 + std::fs::write(&invalid_image_path, b"This is not a valid PNG file").unwrap(); 558 + 559 + let result = get_placeholder(&invalid_image_path, None); 560 + 561 + // Should return an error, not panic 562 + assert!(result.is_err()); 563 + 564 + if let Err(crate::errors::AssetError::ImageLoadFailed { path, .. }) = result { 565 + assert_eq!(path, invalid_image_path); 566 + } else { 567 + panic!("Expected ImageLoadFailed error"); 568 + } 569 + 570 + // Cleanup 571 + std::fs::remove_file(&invalid_image_path).ok(); 572 + } 573 + 574 + #[test] 575 + fn test_placeholder_with_valid_image() { 576 + use std::path::Path; 577 + 578 + // Try to find an existing image in the examples directory 579 + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); 580 + let test_image = project_root.join("examples/image-processing/images/walrus.jpg"); 581 + 582 + // Skip test if the image doesn't exist (e.g., in CI without examples) 583 + if !test_image.exists() { 584 + eprintln!("Skipping test: test image not found at {:?}", test_image); 585 + return; 586 + } 587 + 588 + let result = get_placeholder(&test_image, None); 589 + 590 + // Should succeed 591 + assert!(result.is_ok()); 592 + 593 + let placeholder = result.unwrap(); 594 + // Verify the placeholder has a thumbhash 595 + assert!(!placeholder.thumbhash.is_empty()); 596 + assert!(!placeholder.thumbhash_base64.is_empty()); 597 + } 598 + 599 + #[test] 600 + fn test_placeholder_with_empty_file() { 601 + let temp_dir = setup_unique_temp_dir(); 602 + 603 + // Create an empty file 604 + let empty_file_path = temp_dir.path().join("empty.png"); 605 + std::fs::write(&empty_file_path, b"").unwrap(); 606 + 607 + let result = get_placeholder(&empty_file_path, None); 608 + 609 + // Should return an error for empty/invalid image 610 + assert!(result.is_err()); 611 + } 612 + }
+6
crates/maudit/src/errors.rs
··· 53 #[source] 54 source: std::io::Error, 55 }, 56 } 57 58 #[derive(Error, Debug)]
··· 53 #[source] 54 source: std::io::Error, 55 }, 56 + #[error("Failed to load image for placeholder generation: {path}")] 57 + ImageLoadFailed { 58 + path: PathBuf, 59 + #[source] 60 + source: image::ImageError, 61 + }, 62 } 63 64 #[derive(Error, Debug)]
+17 -1
crates/maudit/src/routing.rs
··· 56 57 #[cfg(test)] 58 mod tests { 59 - use crate::routing::{ParameterDef, extract_params_from_raw_route}; 60 61 #[test] 62 fn test_extract_params() { ··· 123 }]; 124 125 assert_eq!(extract_params_from_raw_route(input), expected); 126 } 127 }
··· 56 57 #[cfg(test)] 58 mod tests { 59 + use crate::routing::{ParameterDef, extract_params_from_raw_route, guess_if_route_is_endpoint}; 60 61 #[test] 62 fn test_extract_params() { ··· 123 }]; 124 125 assert_eq!(extract_params_from_raw_route(input), expected); 126 + } 127 + 128 + #[test] 129 + fn test_guess_if_route_is_endpoint() { 130 + // Routes with file extensions should be detected as endpoints 131 + assert!(guess_if_route_is_endpoint("/api/data.json")); 132 + assert!(guess_if_route_is_endpoint("/feed.xml")); 133 + assert!(guess_if_route_is_endpoint("/sitemap.xml")); 134 + assert!(guess_if_route_is_endpoint("/robots.txt")); 135 + assert!(guess_if_route_is_endpoint("/path/to/file.tar.gz")); 136 + assert!(guess_if_route_is_endpoint("/api/users/[id].json")); 137 + 138 + assert!(!guess_if_route_is_endpoint("/")); 139 + assert!(!guess_if_route_is_endpoint("/articles")); 140 + assert!(!guess_if_route_is_endpoint("/articles/[slug]")); 141 + assert!(!guess_if_route_is_endpoint("/blog/posts/[year]/[month]")); 142 } 143 }
+9
e2e/fixtures/hot-reload/Cargo.toml
···
··· 1 + [package] 2 + name = "fixtures-hot-reload" 3 + version = "0.1.0" 4 + edition = "2024" 5 + publish = false 6 + 7 + [dependencies] 8 + maudit.workspace = true 9 + maud.workspace = true
+14
e2e/fixtures/hot-reload/src/main.rs
···
··· 1 + use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 + 3 + mod pages { 4 + mod index; 5 + pub use index::Index; 6 + } 7 + 8 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 9 + coronate( 10 + routes![pages::Index], 11 + content_sources![], 12 + BuildOptions::default(), 13 + ) 14 + }
+30
e2e/fixtures/hot-reload/src/pages/index.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + 4 + #[route("/")] 5 + pub struct Index; 6 + 7 + impl Route for Index { 8 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 9 + Ok(html! { 10 + html { 11 + head { 12 + title { "Hot Reload Test" } 13 + } 14 + body { 15 + h1 id="title" { "Original Title" } 16 + div id="content" { 17 + p id="message" { "Original message" } 18 + ul id="list" { 19 + li { "Item 1" } 20 + li { "Item 2" } 21 + } 22 + } 23 + footer { 24 + p { "Footer content" } 25 + } 26 + } 27 + } 28 + }) 29 + } 30 + }
+58
e2e/tests/hot-reload.spec.ts
···
··· 1 + import { expect } from "@playwright/test"; 2 + import { createTestWithFixture } from "./test-utils"; 3 + import { readFileSync, writeFileSync } from "node:fs"; 4 + import { resolve, dirname } from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + 7 + const __filename = fileURLToPath(import.meta.url); 8 + const __dirname = dirname(__filename); 9 + 10 + // Create test instance with hot-reload fixture 11 + const test = createTestWithFixture("hot-reload"); 12 + 13 + test.describe.configure({ mode: "serial" }); 14 + 15 + test.describe("Hot Reload", () => { 16 + const fixturePath = resolve(__dirname, "..", "fixtures", "hot-reload"); 17 + const indexPath = resolve(fixturePath, "src", "pages", "index.rs"); 18 + let originalContent: string; 19 + 20 + test.beforeAll(async () => { 21 + // Save original content 22 + originalContent = readFileSync(indexPath, "utf-8"); 23 + }); 24 + 25 + test.afterEach(async () => { 26 + // Restore original content after each test 27 + writeFileSync(indexPath, originalContent, "utf-8"); 28 + // Wait a bit for the rebuild 29 + await new Promise((resolve) => setTimeout(resolve, 2000)); 30 + }); 31 + 32 + test.afterAll(async () => { 33 + // Restore original content 34 + writeFileSync(indexPath, originalContent, "utf-8"); 35 + }); 36 + 37 + test("should show updated content after file changes", async ({ page, devServer }) => { 38 + await page.goto(devServer.url); 39 + 40 + // Verify initial content 41 + await expect(page.locator("#title")).toHaveText("Original Title"); 42 + 43 + // Prepare to wait for actual reload by waiting for the same URL to reload 44 + const currentUrl = page.url(); 45 + 46 + // Modify the file 47 + const modifiedContent = originalContent.replace( 48 + 'h1 id="title" { "Original Title" }', 49 + 'h1 id="title" { "Another Update" }', 50 + ); 51 + writeFileSync(indexPath, modifiedContent, "utf-8"); 52 + 53 + // Wait for the page to actually reload on the same URL 54 + await page.waitForURL(currentUrl, { timeout: 15000 }); 55 + // Verify the updated content 56 + await expect(page.locator("#title")).toHaveText("Another Update", { timeout: 15000 }); 57 + }); 58 + });
+3 -1
e2e/tests/prefetch.spec.ts
··· 1 - import { test, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 4 test.describe("Prefetch", () => { 5 test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
··· 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 5 6 test.describe("Prefetch", () => { 7 test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
+3 -1
e2e/tests/prerender.spec.ts
··· 1 - import { test, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 4 test.describe("Prefetch - Speculation Rules (Prerender)", () => { 5 test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
··· 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 5 6 test.describe("Prefetch - Speculation Rules (Prerender)", () => { 7 test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
+42 -21
e2e/tests/test-utils.ts
··· 136 } 137 138 // Worker-scoped server pool - one server per worker, shared across all tests in that worker 139 - const workerServers = new Map<number, DevServer>(); 140 141 - // Extend Playwright's test with a devServer fixture 142 - export const test = base.extend<{ devServer: DevServer }>({ 143 - devServer: async ({}, use, testInfo) => { 144 - // Use worker index to get or create a server for this worker 145 - const workerIndex = testInfo.workerIndex; 146 147 - let server = workerServers.get(workerIndex); 148 149 - if (!server) { 150 - // Assign unique port based on worker index 151 - const port = 1864 + workerIndex; 152 153 - server = await startDevServer({ 154 - fixture: "prefetch-prerender", 155 - port, 156 - }); 157 158 - workerServers.set(workerIndex, server); 159 - } 160 161 - await use(server); 162 163 - // Don't stop the server here - it stays alive for all tests in this worker 164 - // Playwright will clean up when the worker exits 165 - }, 166 - }); 167 168 export { expect } from "@playwright/test";
··· 136 } 137 138 // Worker-scoped server pool - one server per worker, shared across all tests in that worker 139 + // Key format: "workerIndex-fixtureName" 140 + const workerServers = new Map<string, DevServer>(); 141 142 + /** 143 + * Create a test instance with a devServer fixture for a specific fixture. 144 + * This allows each test file to use a different fixture while sharing the same pattern. 145 + * 146 + * @param fixtureName - Name of the fixture directory under e2e/fixtures/ 147 + * @param basePort - Starting port number (default: 1864). Each worker gets basePort + workerIndex 148 + * 149 + * @example 150 + * ```ts 151 + * import { createTestWithFixture } from "./test-utils"; 152 + * const test = createTestWithFixture("my-fixture"); 153 + * 154 + * test("my test", async ({ devServer }) => { 155 + * // devServer is automatically started for "my-fixture" 156 + * }); 157 + * ``` 158 + */ 159 + export function createTestWithFixture(fixtureName: string, basePort = 1864) { 160 + return base.extend<{ devServer: DevServer }>({ 161 + // oxlint-disable-next-line no-empty-pattern 162 + devServer: async ({}, use, testInfo) => { 163 + // Use worker index to get or create a server for this worker 164 + const workerIndex = testInfo.workerIndex; 165 + const serverKey = `${workerIndex}-${fixtureName}`; 166 167 + let server = workerServers.get(serverKey); 168 169 + if (!server) { 170 + // Assign unique port based on worker index 171 + const port = basePort + workerIndex; 172 173 + server = await startDevServer({ 174 + fixture: fixtureName, 175 + port, 176 + }); 177 178 + workerServers.set(serverKey, server); 179 + } 180 181 + await use(server); 182 183 + // Don't stop the server here - it stays alive for all tests in this worker 184 + // Playwright will clean up when the worker exits 185 + }, 186 + }); 187 + } 188 189 export { expect } from "@playwright/test";
+2 -1
package.json
··· 12 "lint:fix": "oxlint --fix --type-aware && cargo clippy --fix --allow-dirty --allow-staged", 13 "format": "pnpm run format:ts && pnpm run format:rs", 14 "format:ts": "oxfmt", 15 - "format:rs": "cargo fmt" 16 }, 17 "dependencies": { 18 "@tailwindcss/cli": "^4.1.18",
··· 12 "lint:fix": "oxlint --fix --type-aware && cargo clippy --fix --allow-dirty --allow-staged", 13 "format": "pnpm run format:ts && pnpm run format:rs", 14 "format:ts": "oxfmt", 15 + "format:rs": "cargo fmt", 16 + "test:e2e": "cd e2e && pnpm run test" 17 }, 18 "dependencies": { 19 "@tailwindcss/cli": "^4.1.18",