···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 {
00158 get_placeholder(&self.path, self.cache.as_ref())
159 }
160···258 }
259}
260261-fn get_placeholder(path: &PathBuf, cache: Option<&ImageCache>) -> ImagePlaceholder {
000262 // 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 }
270271 let total_start = Instant::now();
272273 let load_start = Instant::now();
274- let image = image::open(path).ok().unwrap();
000275 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 }
331332- ImagePlaceholder::new(thumb_hash, thumbhash_base64)
333}
334335/// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234
···516 ).into()
517 }
518}
000000000000000000000000000000000000000000000
···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}
262263+fn get_placeholder(
264+ path: &PathBuf,
265+ cache: Option<&ImageCache>,
266+) -> Result<ImagePlaceholder, crate::errors::AssetError> {
267 // Check cache first if provided
268 if let Some(cache) = cache
269 && let Some(cached) = cache.get_placeholder(path)
270 {
271 debug!("Using cached placeholder for {}", path.display());
272 let thumbhash_base64 = base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash);
273+ return Ok(ImagePlaceholder::new(cached.thumbhash, thumbhash_base64));
274 }
275276 let total_start = Instant::now();
277278 let load_start = Instant::now();
279+ let image = image::open(path).map_err(|e| crate::errors::AssetError::ImageLoadFailed {
280+ path: path.clone(),
281+ source: e,
282+ })?;
283 let (width, height) = image.dimensions();
284 let (width, height) = (width as usize, height as usize);
285 debug!(
···337 cache.cache_placeholder(path, thumb_hash.clone());
338 }
339340+ Ok(ImagePlaceholder::new(thumb_hash, thumbhash_base64))
341}
342343/// Port of https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js#L234
···524 ).into()
525 }
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+}
+167-46
crates/maudit/src/assets.rs
···432}
433434fn 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());
0437438 let mut filename = PathBuf::new();
439 filename.push(format!("{}.{}", sanitized_stem, hash));
···532533#[cfg(test)]
534mod tests {
535- use super::*;
536- use std::env;
0000000537538- 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();
542543- 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();
00000000546 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();
00554555 assert!(page_assets.styles.len() == 1);
556 }
···561 let mut page_assets = RouteAssets::default();
562563 page_assets
564- .include_style(temp_dir.join("style.css"))
565 .unwrap();
566567 assert!(page_assets.styles.len() == 1);
···573 let temp_dir = setup_temp_dir();
574 let mut page_assets = RouteAssets::default();
575576- page_assets.add_script(temp_dir.join("script.js")).unwrap();
00577 assert!(page_assets.scripts.len() == 1);
578 }
579···583 let mut page_assets = RouteAssets::default();
584585 page_assets
586- .include_script(temp_dir.join("script.js"))
587 .unwrap();
588589 assert!(page_assets.scripts.len() == 1);
···595 let temp_dir = setup_temp_dir();
596 let mut page_assets = RouteAssets::default();
597598- page_assets.add_image(temp_dir.join("image.png")).unwrap();
00599 assert!(page_assets.images.len() == 1);
600 }
601···604 let temp_dir = setup_temp_dir();
605 let mut page_assets = RouteAssets::default();
606607- let image = page_assets.add_image(temp_dir.join("image.png")).unwrap();
00608 assert_eq!(image.url().chars().next(), Some('/'));
609610- let script = page_assets.add_script(temp_dir.join("script.js")).unwrap();
00611 assert_eq!(script.url().chars().next(), Some('/'));
612613- let style = page_assets.add_style(temp_dir.join("style.css")).unwrap();
00614 assert_eq!(style.url().chars().next(), Some('/'));
615 }
616···619 let temp_dir = setup_temp_dir();
620 let mut page_assets = RouteAssets::default();
621622- let image = page_assets.add_image(temp_dir.join("image.png")).unwrap();
00623 assert!(image.url().contains(&image.hash));
624625- let script = page_assets.add_script(temp_dir.join("script.js")).unwrap();
00626 assert!(script.url().contains(&script.hash));
627628- let style = page_assets.add_style(temp_dir.join("style.css")).unwrap();
00629 assert!(style.url().contains(&style.hash));
630 }
631···634 let temp_dir = setup_temp_dir();
635 let mut page_assets = RouteAssets::default();
636637- let image = page_assets.add_image(temp_dir.join("image.png")).unwrap();
00638 assert!(image.build_path().to_string_lossy().contains(&image.hash));
639640- let script = page_assets.add_script(temp_dir.join("script.js")).unwrap();
00641 assert!(script.build_path().to_string_lossy().contains(&script.hash));
642643- let style = page_assets.add_style(temp_dir.join("style.css")).unwrap();
00644 assert!(style.build_path().to_string_lossy().contains(&style.hash));
645 }
646647 #[test]
648 fn test_image_hash_different_options() {
649 let temp_dir = setup_temp_dir();
650- let image_path = temp_dir.join("image.png");
651652- // 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();
661662- let mut page_assets = RouteAssets::default();
000000663664 // 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");
720721 // Create a simple test PNG (1x1 transparent pixel)
722 let png_data = [
···728 ];
729 std::fs::write(&image_path, png_data).unwrap();
730731- let mut page_assets = RouteAssets::default();
000000732733 // 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");
766767- let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None);
000000768769 // Test that different tailwind options produce different hashes
770 let style_default = page_assets.add_style(&style_path).unwrap();
···784785 // 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");
789790 std::fs::write(&style1_path, content).unwrap();
791 std::fs::write(&style2_path, content).unwrap();
792793- let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None);
000000794795 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");
808809- let assets_options = RouteAssetsOptions::default();
810- let mut page_assets = RouteAssets::new(&assets_options, None);
00000811812 // 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 );
00000000000000000000000000000000000000000000000000000000000826 }
827}
···432}
433434fn 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);
438439 let mut filename = PathBuf::new();
440 filename.push(format!("{}.{}", sanitized_stem, hash));
···533534#[cfg(test)]
535mod tests {
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+ };
545546+ fn setup_temp_dir() -> tempfile::TempDir {
547+ let temp_dir = tempfile::tempdir().unwrap();
00548549+ 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();
560 temp_dir
561 }
562···564 fn test_add_style() {
565 let temp_dir = setup_temp_dir();
566 let mut page_assets = RouteAssets::default();
567+ page_assets
568+ .add_style(temp_dir.path().join("style.css"))
569+ .unwrap();
570571 assert!(page_assets.styles.len() == 1);
572 }
···577 let mut page_assets = RouteAssets::default();
578579 page_assets
580+ .include_style(temp_dir.path().join("style.css"))
581 .unwrap();
582583 assert!(page_assets.styles.len() == 1);
···589 let temp_dir = setup_temp_dir();
590 let mut page_assets = RouteAssets::default();
591592+ page_assets
593+ .add_script(temp_dir.path().join("script.js"))
594+ .unwrap();
595 assert!(page_assets.scripts.len() == 1);
596 }
597···601 let mut page_assets = RouteAssets::default();
602603 page_assets
604+ .include_script(temp_dir.path().join("script.js"))
605 .unwrap();
606607 assert!(page_assets.scripts.len() == 1);
···613 let temp_dir = setup_temp_dir();
614 let mut page_assets = RouteAssets::default();
615616+ page_assets
617+ .add_image(temp_dir.path().join("image.png"))
618+ .unwrap();
619 assert!(page_assets.images.len() == 1);
620 }
621···624 let temp_dir = setup_temp_dir();
625 let mut page_assets = RouteAssets::default();
626627+ let image = page_assets
628+ .add_image(temp_dir.path().join("image.png"))
629+ .unwrap();
630 assert_eq!(image.url().chars().next(), Some('/'));
631632+ let script = page_assets
633+ .add_script(temp_dir.path().join("script.js"))
634+ .unwrap();
635 assert_eq!(script.url().chars().next(), Some('/'));
636637+ let style = page_assets
638+ .add_style(temp_dir.path().join("style.css"))
639+ .unwrap();
640 assert_eq!(style.url().chars().next(), Some('/'));
641 }
642···645 let temp_dir = setup_temp_dir();
646 let mut page_assets = RouteAssets::default();
647648+ let image = page_assets
649+ .add_image(temp_dir.path().join("image.png"))
650+ .unwrap();
651 assert!(image.url().contains(&image.hash));
652653+ let script = page_assets
654+ .add_script(temp_dir.path().join("script.js"))
655+ .unwrap();
656 assert!(script.url().contains(&script.hash));
657658+ let style = page_assets
659+ .add_style(temp_dir.path().join("style.css"))
660+ .unwrap();
661 assert!(style.url().contains(&style.hash));
662 }
663···666 let temp_dir = setup_temp_dir();
667 let mut page_assets = RouteAssets::default();
668669+ let image = page_assets
670+ .add_image(temp_dir.path().join("image.png"))
671+ .unwrap();
672 assert!(image.build_path().to_string_lossy().contains(&image.hash));
673674+ let script = page_assets
675+ .add_script(temp_dir.path().join("script.js"))
676+ .unwrap();
677 assert!(script.build_path().to_string_lossy().contains(&script.hash));
678679+ let style = page_assets
680+ .add_style(temp_dir.path().join("style.css"))
681+ .unwrap();
682 assert!(style.build_path().to_string_lossy().contains(&style.hash));
683 }
684685 #[test]
686 fn test_image_hash_different_options() {
687 let temp_dir = setup_temp_dir();
688+ let image_path = temp_dir.path().join("image.png");
689690+ 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();
00000694695+ let mut page_assets = RouteAssets::new(
696+ &RouteAssetsOptions {
697+ hashing_strategy: AssetHashingStrategy::Precise,
698+ ..Default::default()
699+ },
700+ None,
701+ );
702703 // Test that different options produce different hashes
704 let image_default = page_assets.add_image(&image_path).unwrap();
···755 #[test]
756 fn test_image_hash_same_options() {
757 let temp_dir = setup_temp_dir();
758+ let image_path = temp_dir.path().join("image.png");
759760 // Create a simple test PNG (1x1 transparent pixel)
761 let png_data = [
···767 ];
768 std::fs::write(&image_path, png_data).unwrap();
769770+ let mut page_assets = RouteAssets::new(
771+ &RouteAssetsOptions {
772+ hashing_strategy: AssetHashingStrategy::Precise,
773+ ..Default::default()
774+ },
775+ None,
776+ );
777778 // Same options should produce same hash
779 let image1 = page_assets
···807 #[test]
808 fn test_style_hash_different_options() {
809 let temp_dir = setup_temp_dir();
810+ let style_path = temp_dir.path().join("style.css");
811812+ let mut page_assets = RouteAssets::new(
813+ &RouteAssetsOptions {
814+ hashing_strategy: AssetHashingStrategy::Precise,
815+ ..Default::default()
816+ },
817+ None,
818+ );
819820 // Test that different tailwind options produce different hashes
821 let style_default = page_assets.add_style(&style_path).unwrap();
···835836 // Create two identical files with different paths
837 let content = "body { background: blue; }";
838+ let style1_path = temp_dir.path().join("style1.css");
839+ let style2_path = temp_dir.path().join("style2.css");
840841 std::fs::write(&style1_path, content).unwrap();
842 std::fs::write(&style2_path, content).unwrap();
843844+ let mut page_assets = RouteAssets::new(
845+ &RouteAssetsOptions {
846+ hashing_strategy: AssetHashingStrategy::Precise,
847+ ..Default::default()
848+ },
849+ None,
850+ );
851852 let style1 = page_assets.add_style(&style1_path).unwrap();
853 let style2 = page_assets.add_style(&style2_path).unwrap();
···861 #[test]
862 fn test_hash_includes_content() {
863 let temp_dir = setup_temp_dir();
864+ let style_path = temp_dir.path().join("dynamic_style.css");
865866+ let mut page_assets = RouteAssets::new(
867+ &RouteAssetsOptions {
868+ hashing_strategy: AssetHashingStrategy::Precise,
869+ ..Default::default()
870+ },
871+ None,
872+ );
873874 // Write first content and get hash
875 std::fs::write(&style_path, "body { background: red; }").unwrap();
···885 hash1, hash2,
886 "Different content should produce different hashes"
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"));
947 }
948}
···13## Running Tests
1415The tests will automatically:
0161. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`)
172. Start the Maudit dev server on the test fixture site
183. Run the tests
···46## Features Tested
4748### Basic Prefetch
049- Creating link elements with `rel="prefetch"`
50- Preventing duplicate prefetches
51- Skipping current page prefetch
52- Blocking cross-origin prefetches
5354### Prerendering (Chromium only)
055- Creating `<script type="speculationrules">` elements
56- Different eagerness levels (immediate, eager, moderate, conservative)
57- Fallback to link prefetch on non-Chromium browsers
···13## Running Tests
1415The tests will automatically:
16+171. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`)
182. Start the Maudit dev server on the test fixture site
193. Run the tests
···47## Features Tested
4849### Basic Prefetch
50+51- Creating link elements with `rel="prefetch"`
52- Preventing duplicate prefetches
53- Skipping current page prefetch
54- Blocking cross-origin prefetches
5556### Prerendering (Chromium only)
57+58- Creating `<script type="speculationrules">` elements
59- Different eagerness levels (immediate, eager, moderate, conservative)
60- Fallback to link prefetch on non-Chromium browsers
···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";
2import { prefetchScript } from "./utils";
0034test.describe("Prefetch", () => {
5 test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({
···1+import { createTestWithFixture, expect } from "./test-utils";
2import { prefetchScript } from "./utils";
3+4+const test = createTestWithFixture("prefetch-prerender");
56test.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";
2import { prefetchScript } from "./utils";
0034test.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";
2import { prefetchScript } from "./utils";
3+4+const test = createTestWithFixture("prefetch-prerender");
56test.describe("Prefetch - Speculation Rules (Prerender)", () => {
7 test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({
+44-23
e2e/tests/test-utils.ts
···1-import { spawn, execFile, type ChildProcess } from "node:child_process";
2-import { join, resolve, dirname } from "node:path";
3import { existsSync } from "node:fs";
4import { fileURLToPath } from "node:url";
5import { test as base } from "@playwright/test";
···136}
137138// Worker-scoped server pool - one server per worker, shared across all tests in that worker
139-const workerServers = new Map<number, DevServer>();
0140141-// 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;
0000000000000000000146147- let server = workerServers.get(workerIndex);
148149- if (!server) {
150- // Assign unique port based on worker index
151- const port = 1864 + workerIndex;
152153- server = await startDevServer({
154- fixture: "prefetch-prerender",
155- port,
156- });
157158- workerServers.set(workerIndex, server);
159- }
160161- await use(server);
162163- // 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-});
0167168export { expect } from "@playwright/test";
···1+import { spawn } from "node:child_process";
2+import { resolve, dirname } from "node:path";
3import { existsSync } from "node:fs";
4import { fileURLToPath } from "node:url";
5import { test as base } from "@playwright/test";
···136}
137138// 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>();
141142+/**
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}`;
166167+ let server = workerServers.get(serverKey);
168169+ if (!server) {
170+ // Assign unique port based on worker index
171+ const port = basePort + workerIndex;
172173+ server = await startDevServer({
174+ fixture: fixtureName,
175+ port,
176+ });
177178+ workerServers.set(serverKey, server);
179+ }
180181+ await use(server);
182183+ // 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+}
188189export { expect } from "@playwright/test";
+1-1
e2e/tests/utils.ts
···4// Find the actual prefetch bundle file (hash changes on each build)
5const distDir = join(process.cwd(), "../crates/maudit/js/dist");
6const prefetchFile = readdirSync(distDir).find(
7- (f) => f.startsWith("prefetch-") && f.endsWith(".js"),
8);
9if (!prefetchFile) throw new Error("Could not find prefetch bundle");
10
···4// Find the actual prefetch bundle file (hash changes on each build)
5const distDir = join(process.cwd(), "../crates/maudit/js/dist");
6const prefetchFile = readdirSync(distDir).find(
7+ (f) => f.startsWith("prefetch") && f.endsWith(".js"),
8);
9if (!prefetchFile) throw new Error("Could not find prefetch bundle");
10
···214215```markdown
216---
217-title: {{ enhance title="Super Title" /}}
218---
219220Here's an image with a caption:
···214215```markdown
216---
217+title: { { enhance title="Super Title" / } }
218---
219220Here's an image with a caption:
+1-1
website/content/docs/images.md
···96impl Route for ImagePage {
97 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
98 let image = ctx.assets.add_image("path/to/image.jpg")?;
99- let placeholder = image.placeholder();
100101 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri()))
102 }
···96impl Route for ImagePage {
97 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
98 let image = ctx.assets.add_image("path/to/image.jpg")?;
99+ let placeholder = image.placeholder()?;
100101 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri()))
102 }
+1-1
website/content/docs/prefetching.md
···4950Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views.
5152-## Possible risks
5354Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening.
55
···4950Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views.
5152+## Possible risks
5354Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening.
55
+2-2
website/content/news/2026-in-the-cursed-lands.md
···55impl Route for ImagePage {
56 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
57 let image = ctx.assets.add_image("path/to/image.jpg")?;
58- let placeholder = image.placeholder();
5960 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri()))
61 }
···7071### Shortcodes
7273-Embedding a YouTube video typically means copying a long, ugly iframe tag and configuring several attributes to ensure proper rendering. It'd be nice to have something friendlier, a code that would be short, you will.
7475```md
76Here's my cool video:
···55impl Route for ImagePage {
56 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
57 let image = ctx.assets.add_image("path/to/image.jpg")?;
58+ let placeholder = image.placeholder()?;
5960 Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri()))
61 }
···7071### Shortcodes
7273+Embedding a YouTube video typically means copying a long, ugly iframe tag and configuring several attributes to ensure proper rendering. It'd be nice to have something friendlier, a code that would be short, if you will.
7475```md
76Here's my cool video:
+1-1
xtask/Cargo.toml
···5publish = false
67[dependencies]
8-rolldown = { package = "brk_rolldown", version = "0.2.3" }
9tokio = { version = "1", features = ["rt"] }
···5publish = false
67[dependencies]
8+rolldown = { package = "brk_rolldown", version = "0.8.0" }
9tokio = { version = "1", features = ["rt"] }