Rust library to generate static websites

Merge branch 'main' into feat/better-hotreload

authored by

Erika and committed by
GitHub
51e30457 3979a95d

+424 -78
+5
.sampo/changesets/valorous-earl-louhi.md
··· 1 + --- 2 + cargo/maudit: patch 3 + --- 4 + 5 + Make placeholders function return Result so that errors can be handled, if need to
+8
Cargo.lock
··· 1662 1662 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 1663 1663 1664 1664 [[package]] 1665 + name = "fixtures-hot-reload" 1666 + version = "0.1.0" 1667 + dependencies = [ 1668 + "maud", 1669 + "maudit", 1670 + ] 1671 + 1672 + [[package]] 1665 1673 name = "fixtures-prefetch-prerender" 1666 1674 version = "0.1.0" 1667 1675 dependencies = [
+167 -46
crates/maudit/src/assets.rs
··· 432 432 } 433 433 434 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()); 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); 437 438 438 439 let mut filename = PathBuf::new(); 439 440 filename.push(format!("{}.{}", sanitized_stem, hash)); ··· 532 533 533 534 #[cfg(test)] 534 535 mod tests { 535 - use super::*; 536 - use std::env; 536 + use std::path::PathBuf; 537 + 538 + use crate::{ 539 + AssetHashingStrategy, 540 + assets::{ 541 + Asset, ImageFormat, ImageOptions, RouteAssets, RouteAssetsOptions, StyleOptions, 542 + make_filename, 543 + }, 544 + }; 537 545 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(); 546 + fn setup_temp_dir() -> tempfile::TempDir { 547 + let temp_dir = tempfile::tempdir().unwrap(); 542 548 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(); 549 + std::fs::write( 550 + temp_dir.path().join("style.css"), 551 + "body { background: red; }", 552 + ) 553 + .unwrap(); 554 + std::fs::write( 555 + temp_dir.path().join("script.js"), 556 + "console.log('Hello, world!');", 557 + ) 558 + .unwrap(); 559 + std::fs::write(temp_dir.path().join("image.png"), b"").unwrap(); 546 560 temp_dir 547 561 } 548 562 ··· 550 564 fn test_add_style() { 551 565 let temp_dir = setup_temp_dir(); 552 566 let mut page_assets = RouteAssets::default(); 553 - page_assets.add_style(temp_dir.join("style.css")).unwrap(); 567 + page_assets 568 + .add_style(temp_dir.path().join("style.css")) 569 + .unwrap(); 554 570 555 571 assert!(page_assets.styles.len() == 1); 556 572 } ··· 561 577 let mut page_assets = RouteAssets::default(); 562 578 563 579 page_assets 564 - .include_style(temp_dir.join("style.css")) 580 + .include_style(temp_dir.path().join("style.css")) 565 581 .unwrap(); 566 582 567 583 assert!(page_assets.styles.len() == 1); ··· 573 589 let temp_dir = setup_temp_dir(); 574 590 let mut page_assets = RouteAssets::default(); 575 591 576 - page_assets.add_script(temp_dir.join("script.js")).unwrap(); 592 + page_assets 593 + .add_script(temp_dir.path().join("script.js")) 594 + .unwrap(); 577 595 assert!(page_assets.scripts.len() == 1); 578 596 } 579 597 ··· 583 601 let mut page_assets = RouteAssets::default(); 584 602 585 603 page_assets 586 - .include_script(temp_dir.join("script.js")) 604 + .include_script(temp_dir.path().join("script.js")) 587 605 .unwrap(); 588 606 589 607 assert!(page_assets.scripts.len() == 1); ··· 595 613 let temp_dir = setup_temp_dir(); 596 614 let mut page_assets = RouteAssets::default(); 597 615 598 - page_assets.add_image(temp_dir.join("image.png")).unwrap(); 616 + page_assets 617 + .add_image(temp_dir.path().join("image.png")) 618 + .unwrap(); 599 619 assert!(page_assets.images.len() == 1); 600 620 } 601 621 ··· 604 624 let temp_dir = setup_temp_dir(); 605 625 let mut page_assets = RouteAssets::default(); 606 626 607 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 627 + let image = page_assets 628 + .add_image(temp_dir.path().join("image.png")) 629 + .unwrap(); 608 630 assert_eq!(image.url().chars().next(), Some('/')); 609 631 610 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 632 + let script = page_assets 633 + .add_script(temp_dir.path().join("script.js")) 634 + .unwrap(); 611 635 assert_eq!(script.url().chars().next(), Some('/')); 612 636 613 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 637 + let style = page_assets 638 + .add_style(temp_dir.path().join("style.css")) 639 + .unwrap(); 614 640 assert_eq!(style.url().chars().next(), Some('/')); 615 641 } 616 642 ··· 619 645 let temp_dir = setup_temp_dir(); 620 646 let mut page_assets = RouteAssets::default(); 621 647 622 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 648 + let image = page_assets 649 + .add_image(temp_dir.path().join("image.png")) 650 + .unwrap(); 623 651 assert!(image.url().contains(&image.hash)); 624 652 625 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 653 + let script = page_assets 654 + .add_script(temp_dir.path().join("script.js")) 655 + .unwrap(); 626 656 assert!(script.url().contains(&script.hash)); 627 657 628 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 658 + let style = page_assets 659 + .add_style(temp_dir.path().join("style.css")) 660 + .unwrap(); 629 661 assert!(style.url().contains(&style.hash)); 630 662 } 631 663 ··· 634 666 let temp_dir = setup_temp_dir(); 635 667 let mut page_assets = RouteAssets::default(); 636 668 637 - let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 669 + let image = page_assets 670 + .add_image(temp_dir.path().join("image.png")) 671 + .unwrap(); 638 672 assert!(image.build_path().to_string_lossy().contains(&image.hash)); 639 673 640 - let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 674 + let script = page_assets 675 + .add_script(temp_dir.path().join("script.js")) 676 + .unwrap(); 641 677 assert!(script.build_path().to_string_lossy().contains(&script.hash)); 642 678 643 - let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 679 + let style = page_assets 680 + .add_style(temp_dir.path().join("style.css")) 681 + .unwrap(); 644 682 assert!(style.build_path().to_string_lossy().contains(&style.hash)); 645 683 } 646 684 647 685 #[test] 648 686 fn test_image_hash_different_options() { 649 687 let temp_dir = setup_temp_dir(); 650 - let image_path = temp_dir.join("image.png"); 688 + let image_path = temp_dir.path().join("image.png"); 651 689 652 - // Create a simple test PNG (1x1 transparent pixel) 653 - let png_data = [ 654 - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 655 - 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 656 - 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, 657 - 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 658 - 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, 659 - ]; 660 - std::fs::write(&image_path, png_data).unwrap(); 690 + let img = image::ImageBuffer::<image::Rgba<u8>, _>::from_fn(1, 1, |_x, _y| { 691 + image::Rgba([255, 0, 0, 255]) 692 + }); 693 + img.save(&image_path).unwrap(); 661 694 662 - let mut page_assets = RouteAssets::default(); 695 + let mut page_assets = RouteAssets::new( 696 + &RouteAssetsOptions { 697 + hashing_strategy: AssetHashingStrategy::Precise, 698 + ..Default::default() 699 + }, 700 + None, 701 + ); 663 702 664 703 // Test that different options produce different hashes 665 704 let image_default = page_assets.add_image(&image_path).unwrap(); ··· 716 755 #[test] 717 756 fn test_image_hash_same_options() { 718 757 let temp_dir = setup_temp_dir(); 719 - let image_path = temp_dir.join("image.png"); 758 + let image_path = temp_dir.path().join("image.png"); 720 759 721 760 // Create a simple test PNG (1x1 transparent pixel) 722 761 let png_data = [ ··· 728 767 ]; 729 768 std::fs::write(&image_path, png_data).unwrap(); 730 769 731 - let mut page_assets = RouteAssets::default(); 770 + let mut page_assets = RouteAssets::new( 771 + &RouteAssetsOptions { 772 + hashing_strategy: AssetHashingStrategy::Precise, 773 + ..Default::default() 774 + }, 775 + None, 776 + ); 732 777 733 778 // Same options should produce same hash 734 779 let image1 = page_assets ··· 762 807 #[test] 763 808 fn test_style_hash_different_options() { 764 809 let temp_dir = setup_temp_dir(); 765 - let style_path = temp_dir.join("style.css"); 810 + let style_path = temp_dir.path().join("style.css"); 766 811 767 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 812 + let mut page_assets = RouteAssets::new( 813 + &RouteAssetsOptions { 814 + hashing_strategy: AssetHashingStrategy::Precise, 815 + ..Default::default() 816 + }, 817 + None, 818 + ); 768 819 769 820 // Test that different tailwind options produce different hashes 770 821 let style_default = page_assets.add_style(&style_path).unwrap(); ··· 784 835 785 836 // Create two identical files with different paths 786 837 let content = "body { background: blue; }"; 787 - let style1_path = temp_dir.join("style1.css"); 788 - let style2_path = temp_dir.join("style2.css"); 838 + let style1_path = temp_dir.path().join("style1.css"); 839 + let style2_path = temp_dir.path().join("style2.css"); 789 840 790 841 std::fs::write(&style1_path, content).unwrap(); 791 842 std::fs::write(&style2_path, content).unwrap(); 792 843 793 - let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 844 + let mut page_assets = RouteAssets::new( 845 + &RouteAssetsOptions { 846 + hashing_strategy: AssetHashingStrategy::Precise, 847 + ..Default::default() 848 + }, 849 + None, 850 + ); 794 851 795 852 let style1 = page_assets.add_style(&style1_path).unwrap(); 796 853 let style2 = page_assets.add_style(&style2_path).unwrap(); ··· 804 861 #[test] 805 862 fn test_hash_includes_content() { 806 863 let temp_dir = setup_temp_dir(); 807 - let style_path = temp_dir.join("dynamic_style.css"); 864 + let style_path = temp_dir.path().join("dynamic_style.css"); 808 865 809 - let assets_options = RouteAssetsOptions::default(); 810 - let mut page_assets = RouteAssets::new(&assets_options, None); 866 + let mut page_assets = RouteAssets::new( 867 + &RouteAssetsOptions { 868 + hashing_strategy: AssetHashingStrategy::Precise, 869 + ..Default::default() 870 + }, 871 + None, 872 + ); 811 873 812 874 // Write first content and get hash 813 875 std::fs::write(&style_path, "body { background: red; }").unwrap(); ··· 823 885 hash1, hash2, 824 886 "Different content should produce different hashes" 825 887 ); 888 + } 889 + 890 + #[test] 891 + fn test_make_filename_normal_path() { 892 + let path = PathBuf::from("/foo/bar/test.png"); 893 + let hash = "abc12".to_string(); 894 + 895 + let filename = make_filename(&path, &hash, Some("png")); 896 + 897 + // Format is: stem.hash with extension hash.ext 898 + assert_eq!(filename.to_string_lossy(), "test.abc12.png"); 899 + } 900 + 901 + #[test] 902 + fn test_make_filename_no_extension() { 903 + let path = PathBuf::from("/foo/bar/test"); 904 + let hash = "abc12".to_string(); 905 + 906 + let filename = make_filename(&path, &hash, None); 907 + 908 + assert_eq!(filename.to_string_lossy(), "test.abc12"); 909 + } 910 + 911 + #[test] 912 + fn test_make_filename_fallback_for_root_path() { 913 + // Root path has no file stem 914 + let path = PathBuf::from("/"); 915 + let hash = "abc12".to_string(); 916 + 917 + let filename = make_filename(&path, &hash, Some("css")); 918 + 919 + // Should fallback to "asset" 920 + assert_eq!(filename.to_string_lossy(), "asset.abc12.css"); 921 + } 922 + 923 + #[test] 924 + fn test_make_filename_fallback_for_dotdot_path() { 925 + // Path ending with ".." has no file stem 926 + let path = PathBuf::from("/foo/.."); 927 + let hash = "xyz99".to_string(); 928 + 929 + let filename = make_filename(&path, &hash, Some("js")); 930 + 931 + // Should fallback to "asset" 932 + assert_eq!(filename.to_string_lossy(), "asset.xyz99.js"); 933 + } 934 + 935 + #[test] 936 + fn test_make_filename_with_special_characters() { 937 + // Test that special characters get sanitized 938 + let path = PathBuf::from("/foo/test:file*.txt"); 939 + let hash = "def45".to_string(); 940 + 941 + let filename = make_filename(&path, &hash, Some("txt")); 942 + 943 + // Special characters should be replaced with underscores 944 + let result = filename.to_string_lossy(); 945 + assert!(result.contains("test_file_")); 946 + assert!(result.ends_with(".def45.txt")); 826 947 } 827 948 }
+58 -5
crates/maudit/src/assets/image.rs
··· 154 154 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 155 155 /// 156 156 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 157 - pub fn placeholder(&self) -> ImagePlaceholder { 157 + /// 158 + /// Returns an error if the image cannot be loaded. 159 + pub fn placeholder(&self) -> Result<ImagePlaceholder, crate::errors::AssetError> { 158 160 get_placeholder(&self.path, self.cache.as_ref()) 159 161 } 160 162 ··· 258 260 } 259 261 } 260 262 261 - fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> ImagePlaceholder { 263 + fn get_placeholder( 264 + path: &PathBuf, 265 + cache: Option<&ImageCache>, 266 + ) -> Result<ImagePlaceholder, crate::errors::AssetError> { 262 267 // Check cache first if provided 263 268 if let Some(cache) = cache 264 269 && let Some(cached) = cache.get_placeholder(path) 265 270 { 266 271 debug!("Using cached placeholder for {}", path.display()); 267 272 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 268 - return ImagePlaceholder::new(cached.thumbhash, thumbhash_base64); 273 + return Ok(ImagePlaceholder::new(cached.thumbhash, thumbhash_base64)); 269 274 } 270 275 271 276 let total_start = Instant::now(); 272 277 273 278 let load_start = Instant::now(); 274 - let image = image::open(path).ok().unwrap(); 279 + let image = image::open(path).map_err(|e| crate::errors::AssetError::ImageLoadFailed { 280 + path: path.clone(), 281 + source: e, 282 + })?; 275 283 let (width, height) = image.dimensions(); 276 284 let (width, height) = (width as usize, height as usize); 277 285 debug!( ··· 329 337 cache.cache_placeholder(path, thumb_hash.clone()); 330 338 } 331 339 332 - ImagePlaceholder::new(thumb_hash, thumbhash_base64) 340 + Ok(ImagePlaceholder::new(thumb_hash, thumbhash_base64)) 333 341 } 334 342 335 343 /// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234 ··· 516 524 ).into() 517 525 } 518 526 } 527 + 528 + #[cfg(test)] 529 + mod tests { 530 + use crate::errors::AssetError; 531 + 532 + use super::*; 533 + use std::{error::Error, path::PathBuf}; 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 + assert!(result.is_err()); 542 + if let Err(AssetError::ImageLoadFailed { path, .. }) = result { 543 + assert_eq!(path, nonexistent_path); 544 + } else { 545 + panic!("Expected ImageLoadFailed error"); 546 + } 547 + } 548 + 549 + #[test] 550 + fn test_placeholder_with_valid_image() { 551 + let temp_dir = tempfile::tempdir().unwrap(); 552 + let image_path = temp_dir.path().join("test.png"); 553 + 554 + // Create a minimal valid 1x1 PNG file using the image crate to ensure correct CRCs 555 + let img = image::ImageBuffer::<image::Rgba<u8>, _>::from_fn(1, 1, |_x, _y| { 556 + image::Rgba([255, 0, 0, 255]) 557 + }); 558 + img.save(&image_path).unwrap(); 559 + 560 + let result = get_placeholder(&image_path, None); 561 + 562 + if let Err(e) = &result { 563 + eprintln!("get_placeholder failed: {:?}", e.source()); 564 + } 565 + 566 + assert!(result.is_ok()); 567 + let placeholder = result.unwrap(); 568 + assert!(!placeholder.thumbhash.is_empty()); 569 + assert!(!placeholder.thumbhash_base64.is_empty()); 570 + } 571 + }
+6
crates/maudit/src/errors.rs
··· 53 53 #[source] 54 54 source: std::io::Error, 55 55 }, 56 + #[error("Failed to load image for placeholder generation: {path}")] 57 + ImageLoadFailed { 58 + path: PathBuf, 59 + #[source] 60 + source: image::ImageError, 61 + }, 56 62 } 57 63 58 64 #[derive(Error, Debug)]
+17 -1
crates/maudit/src/routing.rs
··· 56 56 57 57 #[cfg(test)] 58 58 mod tests { 59 - use crate::routing::{ParameterDef, extract_params_from_raw_route}; 59 + use crate::routing::{ParameterDef, extract_params_from_raw_route, guess_if_route_is_endpoint}; 60 60 61 61 #[test] 62 62 fn test_extract_params() { ··· 123 123 }]; 124 124 125 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]")); 126 142 } 127 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"; 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 3 5 4 6 test.describe("Prefetch", () => { 5 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"; 1 + import { createTestWithFixture, expect } from "./test-utils"; 2 2 import { prefetchScript } from "./utils"; 3 + 4 + const test = createTestWithFixture("prefetch-prerender"); 3 5 4 6 test.describe("Prefetch - Speculation Rules (Prerender)", () => { 5 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 136 } 137 137 138 138 // Worker-scoped server pool - one server per worker, shared across all tests in that worker 139 - const workerServers = new Map<number, DevServer>(); 139 + // Key format: "workerIndex-fixtureName" 140 + const workerServers = new Map<string, DevServer>(); 140 141 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; 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}`; 146 166 147 - let server = workerServers.get(workerIndex); 167 + let server = workerServers.get(serverKey); 148 168 149 - if (!server) { 150 - // Assign unique port based on worker index 151 - const port = 1864 + workerIndex; 169 + if (!server) { 170 + // Assign unique port based on worker index 171 + const port = basePort + workerIndex; 152 172 153 - server = await startDevServer({ 154 - fixture: "prefetch-prerender", 155 - port, 156 - }); 173 + server = await startDevServer({ 174 + fixture: fixtureName, 175 + port, 176 + }); 157 177 158 - workerServers.set(workerIndex, server); 159 - } 178 + workerServers.set(serverKey, server); 179 + } 160 180 161 - await use(server); 181 + await use(server); 162 182 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 - }); 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 + } 167 188 168 189 export { expect } from "@playwright/test";
+2 -1
package.json
··· 12 12 "lint:fix": "oxlint --fix --type-aware && cargo clippy --fix --allow-dirty --allow-staged", 13 13 "format": "pnpm run format:ts && pnpm run format:rs", 14 14 "format:ts": "oxfmt", 15 - "format:rs": "cargo fmt" 15 + "format:rs": "cargo fmt", 16 + "test:e2e": "cd e2e && pnpm run test" 16 17 }, 17 18 "dependencies": { 18 19 "@tailwindcss/cli": "^4.1.18",
+1 -1
website/content/docs/images.md
··· 96 96 impl Route for ImagePage { 97 97 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 98 98 let image = ctx.assets.add_image("path/to/image.jpg")?; 99 - let placeholder = image.placeholder(); 99 + let placeholder = image.placeholder()?; 100 100 101 101 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri())) 102 102 }
+1 -1
website/content/news/2026-in-the-cursed-lands.md
··· 55 55 impl Route for ImagePage { 56 56 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 57 57 let image = ctx.assets.add_image("path/to/image.jpg")?; 58 - let placeholder = image.placeholder(); 58 + let placeholder = image.placeholder()?; 59 59 60 60 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri())) 61 61 }