···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+}
+4-8
crates/maudit/src/assets/image_cache.rs
···338339 #[test]
340 fn test_build_options_integration() {
341- use crate::build::options::{AssetsOptions, BuildOptions};
342343 // Test that BuildOptions can configure the cache directory
344 let custom_cache = PathBuf::from("/tmp/custom_maudit_cache");
345 let build_options = BuildOptions {
346- assets: AssetsOptions {
347- image_cache_dir: custom_cache.clone(),
348- ..Default::default()
349- },
350 ..Default::default()
351 };
352353- // Create cache with build options
354- let cache = ImageCache::with_cache_dir(&build_options.assets.image_cache_dir);
355356 // Verify it uses the configured directory
357- assert_eq!(cache.get_cache_dir(), custom_cache);
358 }
359360 #[test]
···338339 #[test]
340 fn test_build_options_integration() {
341+ use crate::build::options::BuildOptions;
342343 // Test that BuildOptions can configure the cache directory
344 let custom_cache = PathBuf::from("/tmp/custom_maudit_cache");
345 let build_options = BuildOptions {
346+ cache_dir: custom_cache.clone(),
000347 ..Default::default()
348 };
349350+ let cache = ImageCache::with_cache_dir(build_options.assets_cache_dir());
0351352 // Verify it uses the configured directory
353+ assert_eq!(cache.get_cache_dir(), custom_cache.join("assets"));
354 }
355356 #[test]
+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}
+104-13
crates/maudit/src/build/options.rs
···1-use std::{env, path::PathBuf};
23use crate::{assets::RouteAssetsOptions, is_dev, sitemap::SitemapOptions};
4···36/// assets: AssetsOptions {
37/// assets_dir: "_assets".into(),
38/// tailwind_binary_path: "./node_modules/.bin/tailwindcss".into(),
39-/// image_cache_dir: ".cache/maudit/images".into(),
40/// ..Default::default()
41/// },
42/// prefetch: PrefetchOptions {
···61 /// At the speed Maudit operates at, not cleaning the output directory may offer a significant performance improvement at the cost of potentially serving stale content.
62 pub clean_output_dir: bool,
630000000000000000000064 pub assets: AssetsOptions,
6566 pub prefetch: PrefetchOptions,
···124 hashing_strategy: self.assets.hashing_strategy,
125 }
126 }
00000000127}
128129#[derive(Clone)]
···139 /// Note that this value is not automatically joined with the `output_dir` in `BuildOptions`. Use [`BuildOptions::route_assets_options()`] to get a `RouteAssetsOptions` with the correct final path.
140 pub assets_dir: PathBuf,
141142- /// Directory to use for image cache storage.
143- /// Defaults to `target/maudit_cache/images`.
144- ///
145- /// This cache is used to store processed images and their placeholders to speed up subsequent builds.
146- pub image_cache_dir: PathBuf,
147-148 /// Strategy to use when hashing assets for fingerprinting.
149 ///
150 /// Defaults to [`AssetHashingStrategy::Precise`] in production builds, and [`AssetHashingStrategy::FastImprecise`] in development builds. Note that this means that the cache isn't shared between dev and prod builds by default, if you have a lot of assets you may want to set this to the same value in both environments.
···164 Self {
165 tailwind_binary_path: "tailwindcss".into(),
166 assets_dir: "_maudit".into(),
167- image_cache_dir: {
168- let target_dir =
169- env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string());
170- PathBuf::from(target_dir).join("maudit_cache/images")
171- },
172 hashing_strategy: if is_dev() {
173 AssetHashingStrategy::FastImprecise
174 } else {
···196/// ```
197impl Default for BuildOptions {
198 fn default() -> Self {
000000199 Self {
200 base_url: None,
201 output_dir: "dist".into(),
202 static_dir: "static".into(),
203 clean_output_dir: true,
000204 prefetch: PrefetchOptions::default(),
205 assets: AssetsOptions::default(),
206 sitemap: SitemapOptions::default(),
207 }
208 }
209}
000000000000000000000000000000000000000000000000000000000000000000
···1+use std::{fs, path::PathBuf};
23use crate::{assets::RouteAssetsOptions, is_dev, sitemap::SitemapOptions};
4···36/// assets: AssetsOptions {
37/// assets_dir: "_assets".into(),
38/// tailwind_binary_path: "./node_modules/.bin/tailwindcss".into(),
039/// ..Default::default()
40/// },
41/// prefetch: PrefetchOptions {
···60 /// At the speed Maudit operates at, not cleaning the output directory may offer a significant performance improvement at the cost of potentially serving stale content.
61 pub clean_output_dir: bool,
6263+ /// Whether to enable incremental builds.
64+ ///
65+ /// When enabled, Maudit tracks which assets are used by which routes and only rebuilds
66+ /// routes affected by changed files. This can significantly speed up rebuilds when only
67+ /// a few files have changed.
68+ ///
69+ /// Defaults to `true` in dev mode (`maudit dev`) and `false` in production builds.
70+ pub incremental: bool,
71+72+ /// Directory for build cache storage (incremental build state, etc.).
73+ ///
74+ /// Defaults to `target/maudit_cache/{package_name}` where `{package_name}` is derived
75+ /// from the current directory name.
76+ pub cache_dir: PathBuf,
77+78+ /// Directory for caching processed assets (images, etc.).
79+ ///
80+ /// If `None`, defaults to `{cache_dir}/assets`.
81+ pub assets_cache_dir: Option<PathBuf>,
82+83 pub assets: AssetsOptions,
8485 pub prefetch: PrefetchOptions,
···143 hashing_strategy: self.assets.hashing_strategy,
144 }
145 }
146+147+ /// Returns the directory for caching processed assets (images, etc.).
148+ /// Uses `assets_cache_dir` if set, otherwise defaults to `{cache_dir}/assets`.
149+ pub fn assets_cache_dir(&self) -> PathBuf {
150+ self.assets_cache_dir
151+ .clone()
152+ .unwrap_or_else(|| self.cache_dir.join("assets"))
153+ }
154}
155156#[derive(Clone)]
···166 /// Note that this value is not automatically joined with the `output_dir` in `BuildOptions`. Use [`BuildOptions::route_assets_options()`] to get a `RouteAssetsOptions` with the correct final path.
167 pub assets_dir: PathBuf,
168000000169 /// Strategy to use when hashing assets for fingerprinting.
170 ///
171 /// Defaults to [`AssetHashingStrategy::Precise`] in production builds, and [`AssetHashingStrategy::FastImprecise`] in development builds. Note that this means that the cache isn't shared between dev and prod builds by default, if you have a lot of assets you may want to set this to the same value in both environments.
···185 Self {
186 tailwind_binary_path: "tailwindcss".into(),
187 assets_dir: "_maudit".into(),
00000188 hashing_strategy: if is_dev() {
189 AssetHashingStrategy::FastImprecise
190 } else {
···212/// ```
213impl Default for BuildOptions {
214 fn default() -> Self {
215+ let site_name = get_site_name();
216+ let cache_dir = find_target_dir()
217+ .unwrap_or_else(|_| PathBuf::from("target"))
218+ .join("maudit_cache")
219+ .join(&site_name);
220+221 Self {
222 base_url: None,
223 output_dir: "dist".into(),
224 static_dir: "static".into(),
225 clean_output_dir: true,
226+ incremental: is_dev(),
227+ cache_dir,
228+ assets_cache_dir: None,
229 prefetch: PrefetchOptions::default(),
230 assets: AssetsOptions::default(),
231 sitemap: SitemapOptions::default(),
232 }
233 }
234}
235+236+/// Get the site name for cache directory purposes.
237+///
238+/// Uses the current executable's name (which matches the package/binary name),
239+/// falling back to the current directory name.
240+fn get_site_name() -> String {
241+ // Get the binary name from the current executable
242+ std::env::current_exe()
243+ .ok()
244+ .and_then(|p| p.file_name().map(|s| s.to_string_lossy().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+ })
252+}
253+254+/// Find the target directory using multiple strategies
255+///
256+/// This function tries multiple approaches to locate the target directory:
257+/// 1. CARGO_TARGET_DIR / CARGO_BUILD_TARGET_DIR environment variables
258+/// 2. Local ./target directory
259+/// 3. Workspace root target directory (walking up to find [workspace])
260+/// 4. Fallback to relative "target" path
261+fn find_target_dir() -> Result<PathBuf, std::io::Error> {
262+ // 1. Check CARGO_TARGET_DIR and CARGO_BUILD_TARGET_DIR environment variables
263+ for env_var in ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"] {
264+ if let Ok(target_dir) = std::env::var(env_var) {
265+ let path = PathBuf::from(&target_dir);
266+ if path.exists() {
267+ return Ok(path);
268+ }
269+ }
270+ }
271+272+ // 2. Look for target directory in current directory
273+ let local_target = PathBuf::from("target");
274+ if local_target.exists() {
275+ return Ok(local_target);
276+ }
277+278+ // 3. Try to find workspace root by looking for Cargo.toml with [workspace]
279+ let mut current = std::env::current_dir()?;
280+ loop {
281+ let cargo_toml = current.join("Cargo.toml");
282+ if cargo_toml.exists()
283+ && let Ok(content) = fs::read_to_string(&cargo_toml)
284+ && content.contains("[workspace]")
285+ {
286+ let workspace_target = current.join("target");
287+ if workspace_target.exists() {
288+ return Ok(workspace_target);
289+ }
290+ }
291+292+ // Move up to parent directory
293+ if !current.pop() {
294+ break;
295+ }
296+ }
297+298+ // 4. Final fallback to relative path
299+ Ok(PathBuf::from("target"))
300+}
···1+use rustc_hash::{FxHashMap, FxHashSet};
2+use serde::{Deserialize, Serialize};
3+use std::fs;
4+use std::path::{Path, PathBuf};
5+6+/// Identifies a specific route or variant for incremental rebuilds
7+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
8+pub enum RouteIdentifier {
9+ /// A base route with optional page parameters
10+ /// Params are stored as a sorted Vec for hashing purposes
11+ Base {
12+ route_path: String,
13+ params: Option<Vec<(String, Option<String>)>>,
14+ },
15+ /// A variant route with optional page parameters
16+ /// Params are stored as a sorted Vec for hashing purposes
17+ Variant {
18+ variant_id: String,
19+ variant_path: String,
20+ params: Option<Vec<(String, Option<String>)>>,
21+ },
22+}
23+24+impl RouteIdentifier {
25+ pub fn base(route_path: String, params: Option<FxHashMap<String, Option<String>>>) -> Self {
26+ Self::Base {
27+ route_path,
28+ params: params.map(|p| {
29+ let mut sorted: Vec<_> = p.into_iter().collect();
30+ sorted.sort_by(|a, b| a.0.cmp(&b.0));
31+ sorted
32+ }),
33+ }
34+ }
35+36+ pub fn variant(
37+ variant_id: String,
38+ variant_path: String,
39+ params: Option<FxHashMap<String, Option<String>>>,
40+ ) -> Self {
41+ Self::Variant {
42+ variant_id,
43+ variant_path,
44+ params: params.map(|p| {
45+ let mut sorted: Vec<_> = p.into_iter().collect();
46+ sorted.sort_by(|a, b| a.0.cmp(&b.0));
47+ sorted
48+ }),
49+ }
50+ }
51+}
52+53+/// Tracks build state for incremental builds
54+#[derive(Debug, Default, Serialize, Deserialize)]
55+pub struct BuildState {
56+ /// Maps asset paths to routes that use them
57+ /// Key: canonicalized asset path
58+ /// Value: set of routes using this asset
59+ pub asset_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>,
60+61+ /// Maps source file paths to routes defined in them
62+ /// Key: canonicalized source file path (e.g., src/pages/index.rs)
63+ /// Value: set of routes defined in this source file
64+ pub source_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>,
65+66+ /// Maps content file paths to routes that use them
67+ /// Key: canonicalized content file path (e.g., content/articles/hello.md)
68+ /// Value: set of routes using this specific content file
69+ /// This provides granular tracking - if only hello.md changes, only routes
70+ /// that accessed hello.md need to be rebuilt.
71+ pub content_file_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>,
72+73+ /// Maps content file paths to the content source that owns them
74+ /// Key: canonicalized content file path (e.g., content/articles/hello.md)
75+ /// Value: content source name (e.g., "articles")
76+ /// This allows selective re-initialization of only the content sources
77+ /// whose files have changed.
78+ pub content_file_to_source: FxHashMap<PathBuf, String>,
79+80+ /// Stores all bundler input paths from the last build
81+ /// This needs to be preserved to ensure consistent bundling
82+ pub bundler_inputs: Vec<String>,
83+}
84+85+impl BuildState {
86+ pub fn new() -> Self {
87+ Self::default()
88+ }
89+90+ /// Load build state from disk cache
91+ pub fn load(cache_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
92+ let state_path = cache_dir.join("build_state.json");
93+94+ if !state_path.exists() {
95+ return Ok(Self::new());
96+ }
97+98+ let content = fs::read_to_string(&state_path)?;
99+ let state: BuildState = serde_json::from_str(&content)?;
100+ Ok(state)
101+ }
102+103+ /// Save build state to disk cache
104+ pub fn save(&self, cache_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
105+ fs::create_dir_all(cache_dir)?;
106+ let state_path = cache_dir.join("build_state.json");
107+ let content = serde_json::to_string_pretty(self)?;
108+ fs::write(state_path, content)?;
109+ Ok(())
110+ }
111+112+ /// Add an asset->route mapping
113+ pub fn track_asset(&mut self, asset_path: PathBuf, route_id: RouteIdentifier) {
114+ self.asset_to_routes
115+ .entry(asset_path)
116+ .or_default()
117+ .insert(route_id);
118+ }
119+120+ /// Add a source file->route mapping
121+ /// This tracks which .rs file defines which routes for incremental rebuilds
122+ pub fn track_source_file(&mut self, source_path: PathBuf, route_id: RouteIdentifier) {
123+ self.source_to_routes
124+ .entry(source_path)
125+ .or_default()
126+ .insert(route_id);
127+ }
128+129+ /// Add a content file->route mapping
130+ /// This tracks which specific content files are used by which routes for incremental rebuilds.
131+ /// This provides granular tracking - only routes that actually accessed a specific file
132+ /// will be rebuilt when that file changes.
133+ ///
134+ /// The file path is canonicalized before storage to ensure consistent lookups when
135+ /// comparing against absolute paths from the file watcher.
136+ pub fn track_content_file(&mut self, file_path: PathBuf, route_id: RouteIdentifier) {
137+ // Canonicalize the path to ensure consistent matching with absolute paths from the watcher
138+ let canonical_path = file_path.canonicalize().unwrap_or(file_path);
139+ self.content_file_to_routes
140+ .entry(canonical_path)
141+ .or_default()
142+ .insert(route_id);
143+ }
144+145+ /// Add a content file->source mapping
146+ /// This tracks which content source owns each file, allowing selective re-initialization
147+ /// of only the sources whose files have changed.
148+ ///
149+ /// The file path is canonicalized before storage to ensure consistent lookups.
150+ pub fn track_content_file_source(&mut self, file_path: PathBuf, source_name: String) {
151+ let canonical_path = file_path.canonicalize().unwrap_or(file_path);
152+ self.content_file_to_source
153+ .insert(canonical_path, source_name);
154+ }
155+156+ /// Get the names of content sources that have files in the changed files list.
157+ /// Returns `None` if any changed content file is not tracked (new file), indicating
158+ /// that all content sources should be re-initialized.
159+ ///
160+ /// Only considers files that look like content files (have common content extensions).
161+ pub fn get_affected_content_sources(
162+ &self,
163+ changed_files: &[PathBuf],
164+ ) -> Option<FxHashSet<String>> {
165+ let content_extensions = ["md", "mdx", "yaml", "yml", "json", "toml"];
166+ let mut affected_sources = FxHashSet::default();
167+168+ for changed_file in changed_files {
169+ // Skip files that don't look like content files
170+ let is_content_file = changed_file
171+ .extension()
172+ .and_then(|ext| ext.to_str())
173+ .map(|ext| content_extensions.contains(&ext))
174+ .unwrap_or(false);
175+176+ if !is_content_file {
177+ continue;
178+ }
179+180+ // Try to find the source for this file
181+ let canonical = changed_file.canonicalize().ok();
182+183+ let source = canonical
184+ .as_ref()
185+ .and_then(|c| self.content_file_to_source.get(c))
186+ .or_else(|| self.content_file_to_source.get(changed_file));
187+188+ match source {
189+ Some(source_name) => {
190+ affected_sources.insert(source_name.clone());
191+ }
192+ None => {
193+ // Unknown content file - could be a new file
194+ // Fall back to re-initializing all sources
195+ return None;
196+ }
197+ }
198+ }
199+200+ Some(affected_sources)
201+ }
202+203+ /// Get all routes affected by changes to specific files.
204+ ///
205+ /// Returns `Some(routes)` if all changed files were found in the mappings,
206+ /// or `None` if any changed file is untracked (meaning we need a full rebuild).
207+ ///
208+ /// This handles the case where files like those referenced by `include_str!()`
209+ /// are not tracked at the route level - when these change, we fall back to
210+ /// rebuilding all routes to ensure correctness.
211+ ///
212+ /// Note: Existing directories are not considered "untracked" - they are checked
213+ /// via prefix matching, but a new/unknown directory won't trigger a full rebuild.
214+ pub fn get_affected_routes(
215+ &self,
216+ changed_files: &[PathBuf],
217+ ) -> Option<FxHashSet<RouteIdentifier>> {
218+ let mut affected_routes = FxHashSet::default();
219+ let mut has_untracked_file = false;
220+221+ for changed_file in changed_files {
222+ let mut file_was_tracked = false;
223+224+ // Canonicalize the changed file path for consistent comparison
225+ // All asset paths in asset_to_routes are stored as canonical paths
226+ let canonical_changed = changed_file.canonicalize().ok();
227+228+ // Check source file mappings first (for .rs files)
229+ if let Some(canonical) = &canonical_changed
230+ && let Some(routes) = self.source_to_routes.get(canonical)
231+ {
232+ affected_routes.extend(routes.iter().cloned());
233+ file_was_tracked = true;
234+ // Continue to also check asset mappings (a file could be both)
235+ }
236+237+ // Also check with original path for source files
238+ if let Some(routes) = self.source_to_routes.get(changed_file) {
239+ affected_routes.extend(routes.iter().cloned());
240+ file_was_tracked = true;
241+ }
242+243+ // Try exact match with canonical path for assets
244+ if let Some(canonical) = &canonical_changed
245+ && let Some(routes) = self.asset_to_routes.get(canonical)
246+ {
247+ affected_routes.extend(routes.iter().cloned());
248+ file_was_tracked = true;
249+ }
250+251+ // Fallback: try exact match with original path (shouldn't normally match)
252+ if let Some(routes) = self.asset_to_routes.get(changed_file) {
253+ affected_routes.extend(routes.iter().cloned());
254+ file_was_tracked = true;
255+ }
256+257+ // Check if this is a content file with direct file->route tracking
258+ if let Some(canonical) = &canonical_changed
259+ && let Some(routes) = self.content_file_to_routes.get(canonical)
260+ {
261+ affected_routes.extend(routes.iter().cloned());
262+ file_was_tracked = true;
263+ }
264+265+ // Also check with original path for content files
266+ if let Some(routes) = self.content_file_to_routes.get(changed_file) {
267+ affected_routes.extend(routes.iter().cloned());
268+ file_was_tracked = true;
269+ }
270+271+ // Directory prefix check: find all routes using assets within this directory.
272+ // This handles two cases:
273+ // 1. A directory was modified - rebuild all routes using assets in that dir
274+ // 2. A directory was renamed/deleted - the old path no longer exists but we
275+ // still need to rebuild routes that used assets under that path
276+ //
277+ // We do this check if:
278+ // - The path currently exists as a directory, OR
279+ // - The path doesn't exist (could be a deleted/renamed directory)
280+ let is_existing_directory = changed_file.is_dir();
281+ let path_does_not_exist = !changed_file.exists();
282+283+ if is_existing_directory || path_does_not_exist {
284+ // Use original path for prefix matching (canonical won't exist for deleted dirs)
285+ for (asset_path, routes) in &self.asset_to_routes {
286+ if asset_path.starts_with(changed_file) {
287+ affected_routes.extend(routes.iter().cloned());
288+ file_was_tracked = true;
289+ }
290+ }
291+ // Also check source files for directory prefix
292+ for (source_path, routes) in &self.source_to_routes {
293+ if source_path.starts_with(changed_file) {
294+ affected_routes.extend(routes.iter().cloned());
295+ file_was_tracked = true;
296+ }
297+ }
298+ // Also check content files for directory prefix
299+ for (content_path, routes) in &self.content_file_to_routes {
300+ if content_path.starts_with(changed_file) {
301+ affected_routes.extend(routes.iter().cloned());
302+ file_was_tracked = true;
303+ }
304+ }
305+ }
306+307+ // Flag as untracked (triggering full rebuild) if:
308+ // 1. The file wasn't found in any mapping, AND
309+ // 2. It's not a currently-existing directory (new directories are OK to ignore)
310+ //
311+ // For non-existent paths that weren't matched:
312+ // - If the path has a file extension, treat it as a deleted file โ full rebuild
313+ // - If the path has no extension, it might be a deleted directory โ allow
314+ // (we already checked prefix matching above)
315+ //
316+ // This is conservative: we'd rather rebuild too much than too little.
317+ if !file_was_tracked && !is_existing_directory {
318+ if path_does_not_exist {
319+ // For deleted paths, check if it looks like a file (has extension)
320+ // If it has an extension, it was probably a file โ trigger full rebuild
321+ // If no extension, it might have been a directory โ don't trigger
322+ let has_extension = changed_file
323+ .extension()
324+ .map(|ext| !ext.is_empty())
325+ .unwrap_or(false);
326+327+ if has_extension {
328+ has_untracked_file = true;
329+ }
330+ } else {
331+ // Path exists but wasn't tracked โ definitely untracked file
332+ has_untracked_file = true;
333+ }
334+ }
335+ }
336+337+ if has_untracked_file {
338+ // Some files weren't tracked - caller should do a full rebuild
339+ None
340+ } else {
341+ Some(affected_routes)
342+ }
343+ }
344+345+ /// Clear all tracked data (for full rebuild)
346+ pub fn clear(&mut self) {
347+ self.asset_to_routes.clear();
348+ self.source_to_routes.clear();
349+ self.content_file_to_routes.clear();
350+ self.content_file_to_source.clear();
351+ self.bundler_inputs.clear();
352+ }
353+354+ /// Clear the content file to routes mapping.
355+ /// This should be called before re-tracking content files after content sources are re-initialized.
356+ pub fn clear_content_file_mappings(&mut self) {
357+ self.content_file_to_routes.clear();
358+ }
359+360+ /// Clear content file mappings for specific sources.
361+ /// This removes both file->routes and file->source mappings for files owned by the given sources.
362+ /// Called when selectively re-initializing specific content sources.
363+ pub fn clear_content_mappings_for_sources(&mut self, source_names: &FxHashSet<String>) {
364+ // Find all files that belong to the specified sources
365+ let files_to_remove: Vec<PathBuf> = self
366+ .content_file_to_source
367+ .iter()
368+ .filter(|(_, source)| source_names.contains(*source))
369+ .map(|(path, _)| path.clone())
370+ .collect();
371+372+ // Remove file->source mappings only
373+ // We DON'T clear file->routes mappings here because:
374+ // 1. Routes not being rebuilt should keep their mappings
375+ // 2. Routes being rebuilt will have their mappings cleared separately
376+ // via clear_content_file_mappings_for_routes()
377+ for file in &files_to_remove {
378+ self.content_file_to_source.remove(file);
379+ }
380+ }
381+382+ /// Remove content file mappings for specific routes.
383+ /// This is used during incremental builds to clear only the mappings for routes
384+ /// that will be rebuilt, preserving mappings for routes that won't change.
385+ pub fn clear_content_file_mappings_for_routes(&mut self, routes: &FxHashSet<RouteIdentifier>) {
386+ for routes_set in self.content_file_to_routes.values_mut() {
387+ routes_set.retain(|route| !routes.contains(route));
388+ }
389+ // Remove any entries that have no routes left
390+ self.content_file_to_routes
391+ .retain(|_, routes_set| !routes_set.is_empty());
392+ }
393+394+ /// Check if a file path is a known content file.
395+ /// This is used to determine if a new file might be a content file.
396+ #[allow(dead_code)] // Used in tests and potentially useful for debugging
397+ pub fn is_known_content_file(&self, file_path: &Path) -> bool {
398+ if self.content_file_to_routes.contains_key(file_path) {
399+ return true;
400+ }
401+402+ // Try with canonicalized path
403+ if let Ok(canonical) = file_path.canonicalize() {
404+ return self.content_file_to_routes.contains_key(&canonical);
405+ }
406+407+ false
408+ }
409+}
410+411+#[cfg(test)]
412+mod tests {
413+ use super::*;
414+415+ fn make_route(path: &str) -> RouteIdentifier {
416+ RouteIdentifier::base(path.to_string(), None)
417+ }
418+419+ #[test]
420+ fn test_get_affected_routes_exact_match() {
421+ let mut state = BuildState::new();
422+ let asset_path = PathBuf::from("/project/src/assets/logo.png");
423+ let route = make_route("/");
424+425+ state.track_asset(asset_path.clone(), route.clone());
426+427+ // Exact match should work and return Some
428+ let affected = state.get_affected_routes(&[asset_path]).unwrap();
429+ assert_eq!(affected.len(), 1);
430+ assert!(affected.contains(&route));
431+ }
432+433+ #[test]
434+ fn test_get_affected_routes_untracked_file() {
435+ use std::fs;
436+ use tempfile::TempDir;
437+438+ let mut state = BuildState::new();
439+440+ // Create temp files
441+ let temp_dir = TempDir::new().unwrap();
442+ let tracked_file = temp_dir.path().join("logo.png");
443+ let untracked_file = temp_dir.path().join("other.png");
444+ fs::write(&tracked_file, "tracked").unwrap();
445+ fs::write(&untracked_file, "untracked").unwrap();
446+447+ let route = make_route("/");
448+ state.track_asset(tracked_file.clone(), route);
449+450+ // Untracked file that EXISTS should return None (triggers full rebuild)
451+ let affected = state.get_affected_routes(&[untracked_file]);
452+ assert!(affected.is_none());
453+ }
454+455+ #[test]
456+ fn test_get_affected_routes_mixed_tracked_untracked() {
457+ use std::fs;
458+ use tempfile::TempDir;
459+460+ let mut state = BuildState::new();
461+462+ // Create temp files
463+ let temp_dir = TempDir::new().unwrap();
464+ let tracked_file = temp_dir.path().join("logo.png");
465+ let untracked_file = temp_dir.path().join("other.png");
466+ fs::write(&tracked_file, "tracked").unwrap();
467+ fs::write(&untracked_file, "untracked").unwrap();
468+469+ let route = make_route("/");
470+ state.track_asset(tracked_file.canonicalize().unwrap(), route);
471+472+ // If any file is untracked, return None (even if some are tracked)
473+ let affected = state.get_affected_routes(&[tracked_file, untracked_file]);
474+ assert!(affected.is_none());
475+ }
476+477+ #[test]
478+ fn test_get_affected_routes_deleted_directory() {
479+ let mut state = BuildState::new();
480+481+ // Track assets under a directory path
482+ let asset1 = PathBuf::from("/project/src/assets/icons/logo.png");
483+ let asset2 = PathBuf::from("/project/src/assets/icons/favicon.ico");
484+ let asset3 = PathBuf::from("/project/src/assets/styles.css");
485+ let route1 = make_route("/");
486+ let route2 = make_route("/about");
487+488+ state.track_asset(asset1, route1.clone());
489+ state.track_asset(asset2, route1.clone());
490+ state.track_asset(asset3, route2.clone());
491+492+ // Simulate a deleted/renamed directory (path doesn't exist)
493+ // The "icons" directory was renamed, so the old path doesn't exist
494+ let deleted_dir = PathBuf::from("/project/src/assets/icons");
495+496+ // Since the path doesn't exist, it should check prefix matching
497+ let affected = state.get_affected_routes(&[deleted_dir]).unwrap();
498+499+ // Should find route1 (uses assets under /icons/) but not route2
500+ assert_eq!(affected.len(), 1);
501+ assert!(affected.contains(&route1));
502+ }
503+504+ #[test]
505+ fn test_get_affected_routes_multiple_routes_same_asset() {
506+ let mut state = BuildState::new();
507+ let asset_path = PathBuf::from("/project/src/assets/shared.css");
508+ let route1 = make_route("/");
509+ let route2 = make_route("/about");
510+511+ state.track_asset(asset_path.clone(), route1.clone());
512+ state.track_asset(asset_path.clone(), route2.clone());
513+514+ let affected = state.get_affected_routes(&[asset_path]).unwrap();
515+ assert_eq!(affected.len(), 2);
516+ assert!(affected.contains(&route1));
517+ assert!(affected.contains(&route2));
518+ }
519+520+ #[test]
521+ fn test_get_affected_routes_source_file() {
522+ let mut state = BuildState::new();
523+ let source_path = PathBuf::from("/project/src/pages/index.rs");
524+ let route1 = make_route("/");
525+ let route2 = make_route("/about");
526+527+ // Track routes to their source files
528+ state.track_source_file(source_path.clone(), route1.clone());
529+ state.track_source_file(source_path.clone(), route2.clone());
530+531+ // When the source file changes, both routes should be affected
532+ let affected = state.get_affected_routes(&[source_path]).unwrap();
533+ assert_eq!(affected.len(), 2);
534+ assert!(affected.contains(&route1));
535+ assert!(affected.contains(&route2));
536+ }
537+538+ #[test]
539+ fn test_get_affected_routes_source_file_only_matching() {
540+ let mut state = BuildState::new();
541+ let source_index = PathBuf::from("/project/src/pages/index.rs");
542+ let source_about = PathBuf::from("/project/src/pages/about.rs");
543+ let route_index = make_route("/");
544+ let route_about = make_route("/about");
545+546+ state.track_source_file(source_index.clone(), route_index.clone());
547+ state.track_source_file(source_about.clone(), route_about.clone());
548+549+ // Changing only index.rs should only affect the index route
550+ let affected = state.get_affected_routes(&[source_index]).unwrap();
551+ assert_eq!(affected.len(), 1);
552+ assert!(affected.contains(&route_index));
553+ assert!(!affected.contains(&route_about));
554+ }
555+556+ #[test]
557+ fn test_clear_also_clears_source_files() {
558+ let mut state = BuildState::new();
559+ let source_path = PathBuf::from("/project/src/pages/index.rs");
560+ let asset_path = PathBuf::from("/project/src/assets/logo.png");
561+ let route = make_route("/");
562+563+ state.track_source_file(source_path.clone(), route.clone());
564+ state.track_asset(asset_path.clone(), route.clone());
565+566+ assert!(!state.source_to_routes.is_empty());
567+ assert!(!state.asset_to_routes.is_empty());
568+569+ state.clear();
570+571+ assert!(state.source_to_routes.is_empty());
572+ assert!(state.asset_to_routes.is_empty());
573+ }
574+575+ #[test]
576+ fn test_get_affected_routes_new_directory_not_untracked() {
577+ use std::fs;
578+ use tempfile::TempDir;
579+580+ let mut state = BuildState::new();
581+582+ // Create a temporary directory to simulate the "new directory" scenario
583+ let temp_dir = TempDir::new().unwrap();
584+ let new_dir = temp_dir.path().join("new-folder");
585+ fs::create_dir(&new_dir).unwrap();
586+587+ // Track some asset under a different path
588+ let asset_path = PathBuf::from("/project/src/assets/logo.png");
589+ let route = make_route("/");
590+ state.track_asset(asset_path.clone(), route.clone());
591+592+ // When a new directory appears (e.g., from renaming another folder),
593+ // it should NOT trigger a full rebuild (return None), even though
594+ // we don't have any assets tracked under it.
595+ let affected = state.get_affected_routes(&[new_dir]);
596+597+ // Should return Some (not None), meaning we don't trigger full rebuild
598+ // The set should be empty since no assets are under this new directory
599+ assert!(
600+ affected.is_some(),
601+ "New directory should not trigger full rebuild"
602+ );
603+ assert!(affected.unwrap().is_empty());
604+ }
605+606+ #[test]
607+ fn test_get_affected_routes_folder_rename_scenario() {
608+ use std::fs;
609+ use tempfile::TempDir;
610+611+ let mut state = BuildState::new();
612+613+ // Create temp directories to simulate folder rename
614+ let temp_dir = TempDir::new().unwrap();
615+ let new_dir = temp_dir.path().join("icons-renamed");
616+ fs::create_dir(&new_dir).unwrap();
617+618+ // Track assets under the OLD folder path (which no longer exists)
619+ let old_dir = PathBuf::from("/project/src/assets/icons");
620+ let asset1 = PathBuf::from("/project/src/assets/icons/logo.png");
621+ let route = make_route("/blog");
622+ state.track_asset(asset1, route.clone());
623+624+ // Simulate folder rename: old path doesn't exist, new path is a directory
625+ // Both paths are passed as "changed"
626+ let affected = state.get_affected_routes(&[old_dir, new_dir]);
627+628+ // Should return Some (not None) - we found the affected route via prefix matching
629+ // and the new directory doesn't trigger "untracked file" behavior
630+ assert!(
631+ affected.is_some(),
632+ "Folder rename should not trigger full rebuild"
633+ );
634+ let routes = affected.unwrap();
635+ assert_eq!(routes.len(), 1);
636+ assert!(routes.contains(&route));
637+ }
638+639+ #[test]
640+ fn test_get_affected_routes_deleted_untracked_file() {
641+ let mut state = BuildState::new();
642+643+ // Track some assets
644+ let tracked_asset = PathBuf::from("/project/src/assets/logo.png");
645+ let route = make_route("/");
646+ state.track_asset(tracked_asset, route);
647+648+ // Simulate a deleted file that was NEVER tracked
649+ // (e.g., a file used via include_str! that we don't know about)
650+ // This path doesn't exist and isn't in any mapping
651+ let deleted_untracked_file = PathBuf::from("/project/src/content/data.txt");
652+653+ let affected = state.get_affected_routes(&[deleted_untracked_file]);
654+655+ // Since the deleted path has a file extension (.txt), we treat it as
656+ // a deleted file that might have been a dependency we don't track.
657+ // We should trigger a full rebuild (return None) to be safe.
658+ assert!(
659+ affected.is_none(),
660+ "Deleted untracked file with extension should trigger full rebuild"
661+ );
662+ }
663+664+ #[test]
665+ fn test_get_affected_routes_deleted_untracked_directory() {
666+ let mut state = BuildState::new();
667+668+ // Track some assets
669+ let tracked_asset = PathBuf::from("/project/src/assets/logo.png");
670+ let route = make_route("/");
671+ state.track_asset(tracked_asset, route);
672+673+ // Simulate a deleted directory that was NEVER tracked
674+ // This path doesn't exist, isn't in any mapping, and has no extension
675+ let deleted_untracked_dir = PathBuf::from("/project/src/content");
676+677+ let affected = state.get_affected_routes(&[deleted_untracked_dir]);
678+679+ // Since the path has no extension, it might have been a directory.
680+ // We already did prefix matching (found nothing), so we allow this
681+ // without triggering a full rebuild.
682+ assert!(
683+ affected.is_some(),
684+ "Deleted path without extension (possible directory) should not trigger full rebuild"
685+ );
686+ assert!(affected.unwrap().is_empty());
687+ }
688+689+ #[test]
690+ fn test_get_affected_routes_deleted_tracked_file() {
691+ use std::fs;
692+ use tempfile::TempDir;
693+694+ let mut state = BuildState::new();
695+696+ // Create a temp file, track it, then delete it
697+ let temp_dir = TempDir::new().unwrap();
698+ let tracked_file = temp_dir.path().join("logo.png");
699+ fs::write(&tracked_file, "content").unwrap();
700+701+ let canonical_path = tracked_file.canonicalize().unwrap();
702+ let route = make_route("/");
703+ state.track_asset(canonical_path.clone(), route.clone());
704+705+ // Now delete the file
706+ fs::remove_file(&tracked_file).unwrap();
707+708+ // The file no longer exists, but its canonical path is still in our mapping
709+ // When we get the change event, notify gives us the original path
710+ let affected = state.get_affected_routes(std::slice::from_ref(&tracked_file));
711+712+ // This SHOULD find the route because we track by canonical path
713+ // and the original path should match via the mapping lookup
714+ println!("Result for deleted tracked file: {:?}", affected);
715+716+ // The path doesn't exist anymore, so canonicalize() fails.
717+ // We fall back to prefix matching, but exact path matching on
718+ // the non-canonical path should still work if stored that way.
719+ // Let's check what actually happens...
720+ match affected {
721+ Some(routes) => {
722+ // If we found routes, great - the system works
723+ assert!(
724+ routes.contains(&route),
725+ "Should find the route for deleted tracked file"
726+ );
727+ }
728+ None => {
729+ // If None, that means we triggered a full rebuild, which is also safe
730+ // This happens because the file doesn't exist and wasn't found in mappings
731+ println!("Deleted tracked file triggered full rebuild (safe behavior)");
732+ }
733+ }
734+ }
735+736+ #[test]
737+ fn test_track_content_file() {
738+ let mut state = BuildState::new();
739+ let route = make_route("/");
740+ let content_file = PathBuf::from("/project/content/articles/hello.md");
741+742+ state.track_content_file(content_file.clone(), route.clone());
743+744+ assert_eq!(state.content_file_to_routes.len(), 1);
745+ assert!(state.content_file_to_routes.contains_key(&content_file));
746+ assert!(state.content_file_to_routes[&content_file].contains(&route));
747+ }
748+749+ #[test]
750+ fn test_track_content_file_multiple_routes() {
751+ let mut state = BuildState::new();
752+ let route1 = make_route("/");
753+ let route2 = make_route("/blog");
754+ let content_file = PathBuf::from("/project/content/articles/hello.md");
755+756+ state.track_content_file(content_file.clone(), route1.clone());
757+ state.track_content_file(content_file.clone(), route2.clone());
758+759+ assert_eq!(state.content_file_to_routes.len(), 1);
760+ assert_eq!(state.content_file_to_routes[&content_file].len(), 2);
761+ assert!(state.content_file_to_routes[&content_file].contains(&route1));
762+ assert!(state.content_file_to_routes[&content_file].contains(&route2));
763+ }
764+765+ #[test]
766+ fn test_track_content_file_multiple_files() {
767+ let mut state = BuildState::new();
768+ let route = make_route("/");
769+ let file1 = PathBuf::from("/project/content/articles/hello.md");
770+ let file2 = PathBuf::from("/project/content/articles/world.md");
771+772+ state.track_content_file(file1.clone(), route.clone());
773+ state.track_content_file(file2.clone(), route.clone());
774+775+ assert_eq!(state.content_file_to_routes.len(), 2);
776+ assert!(state.content_file_to_routes[&file1].contains(&route));
777+ assert!(state.content_file_to_routes[&file2].contains(&route));
778+ }
779+780+ #[test]
781+ fn test_clear_also_clears_content_files() {
782+ let mut state = BuildState::new();
783+ let route = make_route("/");
784+ let content_file = PathBuf::from("/project/content/articles/hello.md");
785+786+ state.track_content_file(content_file, route);
787+788+ assert!(!state.content_file_to_routes.is_empty());
789+790+ state.clear();
791+792+ assert!(state.content_file_to_routes.is_empty());
793+ }
794+795+ #[test]
796+ fn test_get_affected_routes_content_file() {
797+ let mut state = BuildState::new();
798+ let route1 = make_route("/");
799+ let route2 = make_route("/blog/[slug]");
800+ let route3 = make_route("/about");
801+802+ // Track content file -> route mappings directly
803+ let article1 = PathBuf::from("/project/content/articles/hello.md");
804+ let article2 = PathBuf::from("/project/content/articles/world.md");
805+ let page1 = PathBuf::from("/project/content/pages/about.md");
806+807+ // Route "/" uses article1 and article2
808+ state.track_content_file(article1.clone(), route1.clone());
809+ state.track_content_file(article2.clone(), route1.clone());
810+ // Route "/blog/[slug]" uses only article1
811+ state.track_content_file(article1.clone(), route2.clone());
812+ // Route "/about" uses page1
813+ state.track_content_file(page1.clone(), route3.clone());
814+815+ // When article1 changes, only routes that used article1 should be affected
816+ let affected = state.get_affected_routes(&[article1]).unwrap();
817+ assert_eq!(affected.len(), 2);
818+ assert!(affected.contains(&route1));
819+ assert!(affected.contains(&route2));
820+ assert!(!affected.contains(&route3));
821+822+ // When article2 changes, only route1 should be affected (granular!)
823+ let affected = state.get_affected_routes(&[article2]).unwrap();
824+ assert_eq!(affected.len(), 1);
825+ assert!(affected.contains(&route1));
826+ assert!(!affected.contains(&route2));
827+ assert!(!affected.contains(&route3));
828+829+ // When page1 changes, only route3 should be affected
830+ let affected = state.get_affected_routes(&[page1]).unwrap();
831+ assert_eq!(affected.len(), 1);
832+ assert!(affected.contains(&route3));
833+ assert!(!affected.contains(&route1));
834+ assert!(!affected.contains(&route2));
835+ }
836+837+ #[test]
838+ fn test_get_affected_routes_content_file_multiple_files_changed() {
839+ let mut state = BuildState::new();
840+ let route1 = make_route("/");
841+ let route2 = make_route("/about");
842+843+ // Track content files
844+ let article = PathBuf::from("/project/content/articles/hello.md");
845+ let page = PathBuf::from("/project/content/pages/about.md");
846+847+ state.track_content_file(article.clone(), route1.clone());
848+ state.track_content_file(page.clone(), route2.clone());
849+850+ // When both files change, both routes should be affected
851+ let affected = state.get_affected_routes(&[article, page]).unwrap();
852+ assert_eq!(affected.len(), 2);
853+ assert!(affected.contains(&route1));
854+ assert!(affected.contains(&route2));
855+ }
856+857+ #[test]
858+ fn test_get_affected_routes_content_file_mixed_with_asset() {
859+ let mut state = BuildState::new();
860+ let route1 = make_route("/");
861+ let route2 = make_route("/about");
862+863+ // Track a content file for route1
864+ let article = PathBuf::from("/project/content/articles/hello.md");
865+ state.track_content_file(article.clone(), route1.clone());
866+867+ // Track an asset used by route2
868+ let style = PathBuf::from("/project/src/styles.css");
869+ state.track_asset(style.clone(), route2.clone());
870+871+ // When both content file and asset change
872+ let affected = state.get_affected_routes(&[article, style]).unwrap();
873+ assert_eq!(affected.len(), 2);
874+ assert!(affected.contains(&route1));
875+ assert!(affected.contains(&route2));
876+ }
877+878+ #[test]
879+ fn test_get_affected_routes_unknown_content_file() {
880+ let mut state = BuildState::new();
881+ let route = make_route("/");
882+883+ // Track a content file
884+ let article = PathBuf::from("/project/content/articles/hello.md");
885+ state.track_content_file(article, route);
886+887+ // A new/unknown .md file that isn't tracked
888+ // This could be a newly created file
889+ let new_file = PathBuf::from("/project/content/articles/new-post.md");
890+891+ // Should trigger full rebuild since it's an untracked file with extension
892+ let affected = state.get_affected_routes(&[new_file]);
893+ assert!(
894+ affected.is_none(),
895+ "New untracked content file should trigger full rebuild"
896+ );
897+ }
898+899+ #[test]
900+ fn test_is_known_content_file() {
901+ let mut state = BuildState::new();
902+ let route = make_route("/");
903+ let content_file = PathBuf::from("/project/content/articles/hello.md");
904+905+ state.track_content_file(content_file.clone(), route);
906+907+ assert!(state.is_known_content_file(&content_file));
908+ assert!(!state.is_known_content_file(Path::new("/project/content/articles/unknown.md")));
909+ }
910+911+ #[test]
912+ fn test_content_file_directory_prefix() {
913+ let mut state = BuildState::new();
914+ let route = make_route("/");
915+916+ // Track content files under a directory
917+ let article1 = PathBuf::from("/project/content/articles/hello.md");
918+ let article2 = PathBuf::from("/project/content/articles/world.md");
919+ state.track_content_file(article1, route.clone());
920+ state.track_content_file(article2, route.clone());
921+922+ // When the parent directory changes (e.g., renamed), should find affected routes
923+ let content_dir = PathBuf::from("/project/content/articles");
924+ let affected = state.get_affected_routes(&[content_dir]).unwrap();
925+ assert_eq!(affected.len(), 1);
926+ assert!(affected.contains(&route));
927+ }
928+929+ #[test]
930+ fn test_clear_content_file_mappings_for_routes() {
931+ let mut state = BuildState::new();
932+ let route1 = make_route("/articles");
933+ let route2 = make_route("/articles/[slug]");
934+ let route3 = make_route("/about");
935+936+ // Article 1 is accessed by routes 1 and 2
937+ let article1 = PathBuf::from("/project/content/articles/hello.md");
938+ state.track_content_file(article1.clone(), route1.clone());
939+ state.track_content_file(article1.clone(), route2.clone());
940+941+ // Article 2 is accessed by routes 1 and 2
942+ let article2 = PathBuf::from("/project/content/articles/world.md");
943+ state.track_content_file(article2.clone(), route1.clone());
944+ state.track_content_file(article2.clone(), route2.clone());
945+946+ // Route 3 uses a different file
947+ let page = PathBuf::from("/project/content/pages/about.md");
948+ state.track_content_file(page.clone(), route3.clone());
949+950+ assert_eq!(state.content_file_to_routes.len(), 3);
951+952+ // Clear mappings only for route2
953+ let mut routes_to_clear = FxHashSet::default();
954+ routes_to_clear.insert(route2.clone());
955+ state.clear_content_file_mappings_for_routes(&routes_to_clear);
956+957+ // route2 should be removed from article1 and article2 mappings
958+ assert!(!state.content_file_to_routes[&article1].contains(&route2));
959+ assert!(state.content_file_to_routes[&article1].contains(&route1));
960+961+ assert!(!state.content_file_to_routes[&article2].contains(&route2));
962+ assert!(state.content_file_to_routes[&article2].contains(&route1));
963+964+ // route3's mapping should be unaffected
965+ assert!(state.content_file_to_routes[&page].contains(&route3));
966+ }
967+968+ #[test]
969+ fn test_clear_content_file_mappings_for_routes_removes_empty_entries() {
970+ let mut state = BuildState::new();
971+ let route1 = make_route("/articles/first");
972+ let route2 = make_route("/articles/second");
973+974+ // Route1 uses only article1
975+ let article1 = PathBuf::from("/project/content/articles/first.md");
976+ state.track_content_file(article1.clone(), route1.clone());
977+978+ // Route2 uses only article2
979+ let article2 = PathBuf::from("/project/content/articles/second.md");
980+ state.track_content_file(article2.clone(), route2.clone());
981+982+ assert_eq!(state.content_file_to_routes.len(), 2);
983+984+ // Clear mappings for route1
985+ let mut routes_to_clear = FxHashSet::default();
986+ routes_to_clear.insert(route1);
987+ state.clear_content_file_mappings_for_routes(&routes_to_clear);
988+989+ // article1 entry should be completely removed (no routes left)
990+ assert!(!state.content_file_to_routes.contains_key(&article1));
991+992+ // article2 entry should still exist
993+ assert!(state.content_file_to_routes.contains_key(&article2));
994+ assert!(state.content_file_to_routes[&article2].contains(&route2));
995+ }
996+997+ #[test]
998+ fn test_track_content_file_source() {
999+ let mut state = BuildState::new();
1000+ let file = PathBuf::from("/project/content/articles/hello.md");
1001+1002+ state.track_content_file_source(file.clone(), "articles".to_string());
1003+1004+ assert_eq!(state.content_file_to_source.len(), 1);
1005+ assert_eq!(
1006+ state.content_file_to_source.get(&file),
1007+ Some(&"articles".to_string())
1008+ );
1009+ }
1010+1011+ #[test]
1012+ fn test_get_affected_content_sources_single_source() {
1013+ let mut state = BuildState::new();
1014+ let article1 = PathBuf::from("/project/content/articles/hello.md");
1015+ let article2 = PathBuf::from("/project/content/articles/world.md");
1016+1017+ state.track_content_file_source(article1.clone(), "articles".to_string());
1018+ state.track_content_file_source(article2.clone(), "articles".to_string());
1019+1020+ // Change one article file
1021+ let affected = state.get_affected_content_sources(&[article1]).unwrap();
1022+ assert_eq!(affected.len(), 1);
1023+ assert!(affected.contains("articles"));
1024+ }
1025+1026+ #[test]
1027+ fn test_get_affected_content_sources_multiple_sources() {
1028+ let mut state = BuildState::new();
1029+ let article = PathBuf::from("/project/content/articles/hello.md");
1030+ let page = PathBuf::from("/project/content/pages/about.md");
1031+1032+ state.track_content_file_source(article.clone(), "articles".to_string());
1033+ state.track_content_file_source(page.clone(), "pages".to_string());
1034+1035+ // Change both files
1036+ let affected = state
1037+ .get_affected_content_sources(&[article, page])
1038+ .unwrap();
1039+ assert_eq!(affected.len(), 2);
1040+ assert!(affected.contains("articles"));
1041+ assert!(affected.contains("pages"));
1042+ }
1043+1044+ #[test]
1045+ fn test_get_affected_content_sources_unknown_file_returns_none() {
1046+ let mut state = BuildState::new();
1047+ let article = PathBuf::from("/project/content/articles/hello.md");
1048+ state.track_content_file_source(article, "articles".to_string());
1049+1050+ // A new file that's not tracked
1051+ let new_file = PathBuf::from("/project/content/articles/new-post.md");
1052+1053+ // Should return None (need to re-init all sources)
1054+ let affected = state.get_affected_content_sources(&[new_file]);
1055+ assert!(affected.is_none());
1056+ }
1057+1058+ #[test]
1059+ fn test_get_affected_content_sources_ignores_non_content_files() {
1060+ let mut state = BuildState::new();
1061+ let article = PathBuf::from("/project/content/articles/hello.md");
1062+ state.track_content_file_source(article.clone(), "articles".to_string());
1063+1064+ // A non-content file (e.g., .rs file) - should be ignored
1065+ let rust_file = PathBuf::from("/project/src/pages/index.rs");
1066+1067+ // Should return empty set (no content sources affected)
1068+ let affected = state
1069+ .get_affected_content_sources(std::slice::from_ref(&rust_file))
1070+ .unwrap();
1071+ assert!(affected.is_empty());
1072+1073+ // Mixed: content file + non-content file
1074+ let affected = state
1075+ .get_affected_content_sources(&[article, rust_file])
1076+ .unwrap();
1077+ assert_eq!(affected.len(), 1);
1078+ assert!(affected.contains("articles"));
1079+ }
1080+1081+ #[test]
1082+ fn test_clear_content_mappings_for_sources() {
1083+ let mut state = BuildState::new();
1084+ let route1 = make_route("/articles");
1085+ let route2 = make_route("/pages");
1086+1087+ // Set up articles source
1088+ let article1 = PathBuf::from("/project/content/articles/hello.md");
1089+ let article2 = PathBuf::from("/project/content/articles/world.md");
1090+ state.track_content_file_source(article1.clone(), "articles".to_string());
1091+ state.track_content_file_source(article2.clone(), "articles".to_string());
1092+ state.track_content_file(article1.clone(), route1.clone());
1093+ state.track_content_file(article2.clone(), route1.clone());
1094+1095+ // Set up pages source
1096+ let page = PathBuf::from("/project/content/pages/about.md");
1097+ state.track_content_file_source(page.clone(), "pages".to_string());
1098+ state.track_content_file(page.clone(), route2.clone());
1099+1100+ assert_eq!(state.content_file_to_source.len(), 3);
1101+ assert_eq!(state.content_file_to_routes.len(), 3);
1102+1103+ // Clear only the articles source
1104+ let mut sources_to_clear = FxHashSet::default();
1105+ sources_to_clear.insert("articles".to_string());
1106+ state.clear_content_mappings_for_sources(&sources_to_clear);
1107+1108+ // Articles source mappings should be removed
1109+ assert!(!state.content_file_to_source.contains_key(&article1));
1110+ assert!(!state.content_file_to_source.contains_key(&article2));
1111+1112+ // But routes mappings should be preserved (cleared separately per-route)
1113+ assert!(state.content_file_to_routes.contains_key(&article1));
1114+ assert!(state.content_file_to_routes.contains_key(&article2));
1115+1116+ // Pages should remain completely unchanged
1117+ assert!(state.content_file_to_source.contains_key(&page));
1118+ assert!(state.content_file_to_routes.contains_key(&page));
1119+ assert_eq!(
1120+ state.content_file_to_source.get(&page),
1121+ Some(&"pages".to_string())
1122+ );
1123+ }
1124+1125+ #[test]
1126+ fn test_clear_also_clears_content_file_to_source() {
1127+ let mut state = BuildState::new();
1128+ let file = PathBuf::from("/project/content/articles/hello.md");
1129+ state.track_content_file_source(file, "articles".to_string());
1130+1131+ assert!(!state.content_file_to_source.is_empty());
1132+1133+ state.clear();
1134+1135+ assert!(state.content_file_to_source.is_empty());
1136+ }
1137+}
···1//! Core functions and structs to define the content sources of your website.
2//!
3//! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded.
4-use std::{any::Any, path::PathBuf, sync::Arc};
0000056-use rustc_hash::FxHashMap;
78mod highlight;
9pub mod markdown;
···25};
2627pub use highlight::{HighlightOptions, highlight_code};
000000000000000000000000000000002829/// Helps implement a struct as a Markdown content entry.
30///
···302 }
303 }
30400000000000000305 pub fn get_untyped_source(&self, name: &str) -> &ContentSource<Untyped> {
306 self.get_source::<Untyped>(name)
307 }
···337/// A source of content such as articles, blog posts, etc.
338pub struct ContentSource<T = Untyped> {
339 pub name: String,
340- pub entries: Vec<Arc<EntryInner<T>>>,
341 pub(crate) init_method: ContentSourceInitMethod<T>,
342}
343···354 }
355356 pub fn get_entry(&self, id: &str) -> &Entry<T> {
357- self.entries
0358 .iter()
359 .find(|entry| entry.id == id)
360- .unwrap_or_else(|| panic!("Entry with id '{}' not found", id))
0000000361 }
362363 pub fn get_entry_safe(&self, id: &str) -> Option<&Entry<T>> {
364- self.entries.iter().find(|entry| entry.id == id)
000000000365 }
366367 pub fn into_params<P>(&self, cb: impl FnMut(&Entry<T>) -> P) -> Vec<P>
368 where
369 P: Into<PageParams>,
370 {
000000371 self.entries.iter().map(cb).collect()
372 }
373···378 where
379 Params: Into<PageParams>,
380 {
000000381 self.entries.iter().map(cb).collect()
382 }
00000000000000383}
384385#[doc(hidden)]
···389 fn init(&mut self);
390 fn get_name(&self) -> &str;
391 fn as_any(&self) -> &dyn Any; // Used for type checking at runtime
0000392}
393394impl<T: 'static + Sync + Send> ContentSourceInternal for ContentSource<T> {
···400 }
401 fn as_any(&self) -> &dyn Any {
402 self
000000403 }
404}
···1//! Core functions and structs to define the content sources of your website.
2//!
3//! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded.
4+use std::{
5+ any::Any,
6+ cell::RefCell,
7+ path::{Path, PathBuf},
8+ sync::Arc,
9+};
1011+use rustc_hash::{FxHashMap, FxHashSet};
1213mod highlight;
14pub mod markdown;
···30};
3132pub use highlight::{HighlightOptions, highlight_code};
33+34+// Thread-local storage for tracking content file access during page rendering.
35+// This allows us to transparently track which content files a page uses
36+// without requiring changes to user code.
37+thread_local! {
38+ static ACCESSED_CONTENT_FILES: RefCell<Option<FxHashSet<PathBuf>>> = const { RefCell::new(None) };
39+}
40+41+/// Start tracking content file access for a page render.
42+/// Call this before rendering a page, then call `finish_tracking_content_files()`
43+/// after rendering to get the set of accessed content files.
44+pub(crate) fn start_tracking_content_files() {
45+ ACCESSED_CONTENT_FILES.with(|cell| {
46+ *cell.borrow_mut() = Some(FxHashSet::default());
47+ });
48+}
49+50+/// Finish tracking content file access and return the set of accessed files.
51+/// Returns `None` if tracking was not started.
52+pub(crate) fn finish_tracking_content_files() -> Option<FxHashSet<PathBuf>> {
53+ ACCESSED_CONTENT_FILES.with(|cell| cell.borrow_mut().take())
54+}
55+56+/// Record that a content file was accessed.
57+/// This is called internally when entries are accessed.
58+fn track_content_file_access(file_path: &Path) {
59+ ACCESSED_CONTENT_FILES.with(|cell| {
60+ if let Some(ref mut set) = *cell.borrow_mut() {
61+ set.insert(file_path.to_path_buf());
62+ }
63+ });
64+}
6566/// Helps implement a struct as a Markdown content entry.
67///
···339 }
340 }
341342+ /// Initialize only the content sources with the given names.
343+ /// Sources not in the set are left untouched (their entries remain as-is).
344+ /// Returns the names of sources that were actually initialized.
345+ pub fn init_sources(&mut self, source_names: &rustc_hash::FxHashSet<String>) -> Vec<String> {
346+ let mut initialized = Vec::new();
347+ for source in &mut self.0 {
348+ if source_names.contains(source.get_name()) {
349+ source.init();
350+ initialized.push(source.get_name().to_string());
351+ }
352+ }
353+ initialized
354+ }
355+356 pub fn get_untyped_source(&self, name: &str) -> &ContentSource<Untyped> {
357 self.get_source::<Untyped>(name)
358 }
···388/// A source of content such as articles, blog posts, etc.
389pub struct ContentSource<T = Untyped> {
390 pub name: String,
391+ entries: Vec<Arc<EntryInner<T>>>,
392 pub(crate) init_method: ContentSourceInitMethod<T>,
393}
394···405 }
406407 pub fn get_entry(&self, id: &str) -> &Entry<T> {
408+ let entry = self
409+ .entries
410 .iter()
411 .find(|entry| entry.id == id)
412+ .unwrap_or_else(|| panic!("Entry with id '{}' not found", id));
413+414+ // Track file access for incremental builds
415+ if let Some(ref file_path) = entry.file_path {
416+ track_content_file_access(file_path);
417+ }
418+419+ entry
420 }
421422 pub fn get_entry_safe(&self, id: &str) -> Option<&Entry<T>> {
423+ let entry = self.entries.iter().find(|entry| entry.id == id);
424+425+ // Track file access for incremental builds
426+ if let Some(entry) = &entry
427+ && let Some(ref file_path) = entry.file_path
428+ {
429+ track_content_file_access(file_path);
430+ }
431+432+ entry
433 }
434435 pub fn into_params<P>(&self, cb: impl FnMut(&Entry<T>) -> P) -> Vec<P>
436 where
437 P: Into<PageParams>,
438 {
439+ // Track all entries accessed for incremental builds
440+ for entry in &self.entries {
441+ if let Some(ref file_path) = entry.file_path {
442+ track_content_file_access(file_path);
443+ }
444+ }
445 self.entries.iter().map(cb).collect()
446 }
447···452 where
453 Params: Into<PageParams>,
454 {
455+ // Track all entries accessed for incremental builds
456+ for entry in &self.entries {
457+ if let Some(ref file_path) = entry.file_path {
458+ track_content_file_access(file_path);
459+ }
460+ }
461 self.entries.iter().map(cb).collect()
462 }
463+464+ /// Get all entries, tracking access for incremental builds.
465+ ///
466+ /// This returns a slice of all entries in the content source.
467+ /// You can use standard slice methods like `.iter()`, `.len()`, `.is_empty()`, etc.
468+ pub fn entries(&self) -> &[Entry<T>] {
469+ // Track all entries accessed for incremental builds
470+ for entry in &self.entries {
471+ if let Some(ref file_path) = entry.file_path {
472+ track_content_file_access(file_path);
473+ }
474+ }
475+ &self.entries
476+ }
477}
478479#[doc(hidden)]
···483 fn init(&mut self);
484 fn get_name(&self) -> &str;
485 fn as_any(&self) -> &dyn Any; // Used for type checking at runtime
486+487+ /// Get all file paths for entries in this content source.
488+ /// Used for incremental builds to map content files to their source.
489+ fn get_entry_file_paths(&self) -> Vec<PathBuf>;
490}
491492impl<T: 'static + Sync + Send> ContentSourceInternal for ContentSource<T> {
···498 }
499 fn as_any(&self) -> &dyn Any {
500 self
501+ }
502+ fn get_entry_file_paths(&self) -> Vec<PathBuf> {
503+ self.entries
504+ .iter()
505+ .filter_map(|entry| entry.file_path.clone())
506+ .collect()
507 }
508}
···54// Internal modules
55mod logging;
5657-use std::env;
05859use build::execute_build;
60use content::ContentSources;
61use logging::init_logging;
62use route::FullRoute;
6300000064/// Returns whether Maudit is running in development mode (through `maudit dev`).
65///
66/// This can be useful to conditionally enable features or logging that should only be active during development.
67/// Oftentimes, this is used to disable some expensive operations that would slow down build times during development.
68pub fn is_dev() -> bool {
69- env::var("MAUDIT_DEV").map(|v| v == "true").unwrap_or(false)
70}
7172#[macro_export]
···212 .enable_all()
213 .build()?;
214215- execute_build(routes, &mut content_sources, &options, &async_runtime)
000000000000216}
···54// Internal modules
55mod logging;
5657+use std::sync::LazyLock;
58+use std::{env, path::PathBuf};
5960use build::execute_build;
61use content::ContentSources;
62use logging::init_logging;
63use route::FullRoute;
6465+static IS_DEV: LazyLock<bool> = LazyLock::new(|| {
66+ std::env::var("MAUDIT_DEV")
67+ .map(|v| v == "true")
68+ .unwrap_or(false)
69+});
70+71/// Returns whether Maudit is running in development mode (through `maudit dev`).
72///
73/// This can be useful to conditionally enable features or logging that should only be active during development.
74/// Oftentimes, this is used to disable some expensive operations that would slow down build times during development.
75pub fn is_dev() -> bool {
76+ *IS_DEV
77}
7879#[macro_export]
···219 .enable_all()
220 .build()?;
221222+ // Check for changed files from environment variable (set by CLI in dev mode)
223+ let changed_files = env::var("MAUDIT_CHANGED_FILES")
224+ .ok()
225+ .and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
226+ .map(|paths| paths.into_iter().map(PathBuf::from).collect::<Vec<_>>());
227+228+ execute_build(
229+ routes,
230+ &mut content_sources,
231+ &options,
232+ changed_files.as_deref(),
233+ &async_runtime,
234+ )
235}
···1//! Blog archetype.
2//! Represents a markdown blog archetype, with an index page and individual entry pages.
3use crate::layouts::layout;
4-use maud::{Markup, html};
5use maudit::content::markdown_entry;
6-use maudit::route::FullRoute;
7use maudit::route::prelude::*;
089pub fn blog_index_content<T: FullRoute>(
10 route: impl FullRoute,
···1819 let markup = html! {
20 main {
21- @for entry in &blog_entries.entries {
22 a href=(route.url(&BlogEntryParams { entry: entry.id.clone() }.into())) {
23 h2 { (entry.data(ctx).title) }
24 p { (entry.data(ctx).description) }
···1//! Blog archetype.
2//! Represents a markdown blog archetype, with an index page and individual entry pages.
3use crate::layouts::layout;
4+use maud::{html, Markup};
5use maudit::content::markdown_entry;
06use maudit::route::prelude::*;
7+use maudit::route::FullRoute;
89pub fn blog_index_content<T: FullRoute>(
10 route: impl FullRoute,
···1819 let markup = html! {
20 main {
21+ @for entry in blog_entries.entries() {
22 a href=(route.url(&BlogEntryParams { entry: entry.id.clone() }.into())) {
23 h2 { (entry.data(ctx).title) }
24 p { (entry.data(ctx).description) }
+3
e2e/README.md
···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, renameSync, existsSync } 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 incremental-build fixture
11+const test = createTestWithFixture("incremental-build");
12+13+// Run tests serially since they share state; allow retries for timing-sensitive tests
14+test.describe.configure({ mode: "serial", retries: 2 });
15+16+/**
17+ * Wait for dev server to complete a build by polling logs.
18+ * Returns logs once build is finished.
19+ */
20+async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> {
21+ const startTime = Date.now();
22+ const pollInterval = 50;
23+24+ // Phase 1: Wait for build to start
25+ while (Date.now() - startTime < timeoutMs) {
26+ const logs = devServer.getLogs(200);
27+ const logsText = logs.join("\n").toLowerCase();
28+29+ if (
30+ logsText.includes("rerunning") ||
31+ logsText.includes("rebuilding") ||
32+ logsText.includes("files changed")
33+ ) {
34+ break;
35+ }
36+37+ await new Promise((r) => setTimeout(r, pollInterval));
38+ }
39+40+ // Phase 2: Wait for build to finish
41+ while (Date.now() - startTime < timeoutMs) {
42+ const logs = devServer.getLogs(200);
43+ const logsText = logs.join("\n").toLowerCase();
44+45+ if (
46+ logsText.includes("finished") ||
47+ logsText.includes("rerun finished") ||
48+ logsText.includes("build finished")
49+ ) {
50+ return logs;
51+ }
52+53+ await new Promise((r) => setTimeout(r, pollInterval));
54+ }
55+56+ console.log("TIMEOUT - logs seen:", devServer.getLogs(50));
57+ throw new Error(`Build did not complete within ${timeoutMs}ms`);
58+}
59+60+/**
61+ * Wait for the dev server to become idle (no builds in progress).
62+ * This polls build IDs until they stop changing.
63+ */
64+async function waitForIdle(htmlPaths: Record<string, string>, stableMs = 200): Promise<void> {
65+ let lastIds = recordBuildIds(htmlPaths);
66+ let stableTime = 0;
67+68+ while (stableTime < stableMs) {
69+ await new Promise((r) => setTimeout(r, 50));
70+ const currentIds = recordBuildIds(htmlPaths);
71+72+ const allSame = Object.keys(lastIds).every(
73+ (key) => lastIds[key] === currentIds[key]
74+ );
75+76+ if (allSame) {
77+ stableTime += 50;
78+ } else {
79+ stableTime = 0;
80+ lastIds = currentIds;
81+ }
82+ }
83+}
84+85+/**
86+ * Wait for a specific HTML file's build ID to change from a known value.
87+ * This is more reliable than arbitrary sleeps.
88+ */
89+async function waitForBuildIdChange(
90+ htmlPath: string,
91+ previousId: string | null,
92+ timeoutMs = 30000,
93+): Promise<string> {
94+ const startTime = Date.now();
95+ const pollInterval = 50;
96+97+ while (Date.now() - startTime < timeoutMs) {
98+ const currentId = getBuildId(htmlPath);
99+ if (currentId !== null && currentId !== previousId) {
100+ // Small delay to let any concurrent writes settle
101+ await new Promise((r) => setTimeout(r, 100));
102+ return currentId;
103+ }
104+ await new Promise((r) => setTimeout(r, pollInterval));
105+ }
106+107+ throw new Error(`Build ID did not change within ${timeoutMs}ms`);
108+}
109+110+/**
111+ * Extract the build ID from an HTML file.
112+ */
113+function getBuildId(htmlPath: string): string | null {
114+ try {
115+ const content = readFileSync(htmlPath, "utf-8");
116+ const match = content.match(/data-build-id="(\d+)"/);
117+ return match ? match[1] : null;
118+ } catch {
119+ return null;
120+ }
121+}
122+123+/**
124+ * Check if logs indicate incremental build was used
125+ */
126+function isIncrementalBuild(logs: string[]): boolean {
127+ return logs.join("\n").toLowerCase().includes("incremental build");
128+}
129+130+/**
131+ * Get the number of affected routes from logs
132+ */
133+function getAffectedRouteCount(logs: string[]): number {
134+ const logsText = logs.join("\n");
135+ const match = logsText.match(/Rebuilding (\d+) affected routes/i);
136+ return match ? parseInt(match[1], 10) : -1;
137+}
138+139+/**
140+ * Record build IDs for all pages
141+ */
142+function recordBuildIds(htmlPaths: Record<string, string>): Record<string, string | null> {
143+ const ids: Record<string, string | null> = {};
144+ for (const [name, path] of Object.entries(htmlPaths)) {
145+ ids[name] = getBuildId(path);
146+ }
147+ return ids;
148+}
149+150+/**
151+ * Trigger a change and wait for build to complete.
152+ * Returns logs from the build.
153+ */
154+async function triggerAndWaitForBuild(
155+ devServer: any,
156+ modifyFn: () => void,
157+ timeoutMs = 30000,
158+): Promise<string[]> {
159+ devServer.clearLogs();
160+ modifyFn();
161+ return await waitForBuildComplete(devServer, timeoutMs);
162+}
163+164+/**
165+ * Set up incremental build state by triggering two builds.
166+ * First build establishes state, second ensures state is populated.
167+ * Returns build IDs recorded after the second build completes and server is idle.
168+ *
169+ * Note: We don't assert incremental here - the actual test will verify that.
170+ * This is because on first test run the server might still be initializing.
171+ */
172+async function setupIncrementalState(
173+ devServer: any,
174+ modifyFn: (suffix: string) => void,
175+ htmlPaths: Record<string, string>,
176+ expectedChangedRoute: string, // Which route we expect to change
177+): Promise<Record<string, string | null>> {
178+ // First change: triggers build (establishes state)
179+ const beforeInit = getBuildId(htmlPaths[expectedChangedRoute]);
180+ await triggerAndWaitForBuild(devServer, () => modifyFn("init"));
181+ await waitForBuildIdChange(htmlPaths[expectedChangedRoute], beforeInit);
182+183+ // Second change: state should now exist for incremental builds
184+ const beforeSetup = getBuildId(htmlPaths[expectedChangedRoute]);
185+ await triggerAndWaitForBuild(devServer, () => modifyFn("setup"));
186+ await waitForBuildIdChange(htmlPaths[expectedChangedRoute], beforeSetup);
187+188+ // Wait for server to become completely idle before recording baseline
189+ await waitForIdle(htmlPaths);
190+191+ return recordBuildIds(htmlPaths);
192+}
193+194+test.describe("Incremental Build", () => {
195+ test.setTimeout(180000);
196+197+ const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build");
198+199+ // Asset paths
200+ const assets = {
201+ blogCss: resolve(fixturePath, "src", "assets", "blog.css"),
202+ utilsJs: resolve(fixturePath, "src", "assets", "utils.js"),
203+ mainJs: resolve(fixturePath, "src", "assets", "main.js"),
204+ aboutJs: resolve(fixturePath, "src", "assets", "about.js"),
205+ stylesCss: resolve(fixturePath, "src", "assets", "styles.css"),
206+ logoPng: resolve(fixturePath, "src", "assets", "logo.png"),
207+ teamPng: resolve(fixturePath, "src", "assets", "team.png"),
208+ bgPng: resolve(fixturePath, "src", "assets", "bg.png"),
209+ };
210+211+ // Content file paths (for granular content tracking tests)
212+ const contentFiles = {
213+ firstPost: resolve(fixturePath, "content", "articles", "first-post.md"),
214+ secondPost: resolve(fixturePath, "content", "articles", "second-post.md"),
215+ thirdPost: resolve(fixturePath, "content", "articles", "third-post.md"),
216+ };
217+218+ // Output HTML paths
219+ const htmlPaths = {
220+ index: resolve(fixturePath, "dist", "index.html"),
221+ about: resolve(fixturePath, "dist", "about", "index.html"),
222+ blog: resolve(fixturePath, "dist", "blog", "index.html"),
223+ articles: resolve(fixturePath, "dist", "articles", "index.html"),
224+ articleFirst: resolve(fixturePath, "dist", "articles", "first-post", "index.html"),
225+ articleSecond: resolve(fixturePath, "dist", "articles", "second-post", "index.html"),
226+ articleThird: resolve(fixturePath, "dist", "articles", "third-post", "index.html"),
227+ };
228+229+ // Original content storage
230+ const originals: Record<string, string | Buffer> = {};
231+232+ test.beforeAll(async () => {
233+ // Store original content for all assets we might modify
234+ originals.blogCss = readFileSync(assets.blogCss, "utf-8");
235+ originals.utilsJs = readFileSync(assets.utilsJs, "utf-8");
236+ originals.mainJs = readFileSync(assets.mainJs, "utf-8");
237+ originals.aboutJs = readFileSync(assets.aboutJs, "utf-8");
238+ originals.stylesCss = readFileSync(assets.stylesCss, "utf-8");
239+ originals.logoPng = readFileSync(assets.logoPng); // binary
240+ originals.teamPng = readFileSync(assets.teamPng); // binary
241+ originals.bgPng = readFileSync(assets.bgPng); // binary
242+ // Content files
243+ originals.firstPost = readFileSync(contentFiles.firstPost, "utf-8");
244+ originals.secondPost = readFileSync(contentFiles.secondPost, "utf-8");
245+ originals.thirdPost = readFileSync(contentFiles.thirdPost, "utf-8");
246+ });
247+248+ test.afterAll(async () => {
249+ // Restore all original content
250+ writeFileSync(assets.blogCss, originals.blogCss);
251+ writeFileSync(assets.utilsJs, originals.utilsJs);
252+ writeFileSync(assets.mainJs, originals.mainJs);
253+ writeFileSync(assets.aboutJs, originals.aboutJs);
254+ writeFileSync(assets.stylesCss, originals.stylesCss);
255+ writeFileSync(assets.logoPng, originals.logoPng);
256+ writeFileSync(assets.teamPng, originals.teamPng);
257+ writeFileSync(assets.bgPng, originals.bgPng);
258+ // Restore content files
259+ writeFileSync(contentFiles.firstPost, originals.firstPost);
260+ writeFileSync(contentFiles.secondPost, originals.secondPost);
261+ writeFileSync(contentFiles.thirdPost, originals.thirdPost);
262+ });
263+264+ // ============================================================
265+ // TEST 1: Direct CSS dependency (blog.css โ /blog only)
266+ // ============================================================
267+ test("CSS file change rebuilds only routes using it", async ({ devServer }) => {
268+ let testCounter = 0;
269+270+ function modifyFile(suffix: string) {
271+ testCounter++;
272+ writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`);
273+ }
274+275+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog");
276+ expect(before.index).not.toBeNull();
277+ expect(before.about).not.toBeNull();
278+ expect(before.blog).not.toBeNull();
279+280+ // Trigger the final change and wait for build
281+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
282+ await waitForBuildIdChange(htmlPaths.blog, before.blog);
283+284+ // Verify incremental build with 1 route
285+ expect(isIncrementalBuild(logs)).toBe(true);
286+ expect(getAffectedRouteCount(logs)).toBe(1);
287+288+ // Verify only blog was rebuilt
289+ const after = recordBuildIds(htmlPaths);
290+ expect(after.index).toBe(before.index);
291+ expect(after.about).toBe(before.about);
292+ expect(after.blog).not.toBe(before.blog);
293+ });
294+295+ // ============================================================
296+ // TEST 2: Transitive JS dependency (utils.js โ main.js โ /)
297+ // ============================================================
298+ test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => {
299+ let testCounter = 0;
300+301+ function modifyFile(suffix: string) {
302+ testCounter++;
303+ writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`);
304+ }
305+306+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index");
307+ expect(before.index).not.toBeNull();
308+309+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
310+ await waitForBuildIdChange(htmlPaths.index, before.index);
311+312+ // Verify incremental build with 1 route
313+ expect(isIncrementalBuild(logs)).toBe(true);
314+ expect(getAffectedRouteCount(logs)).toBe(1);
315+316+ // Only index should be rebuilt (uses main.js which imports utils.js)
317+ const after = recordBuildIds(htmlPaths);
318+ expect(after.about).toBe(before.about);
319+ expect(after.blog).toBe(before.blog);
320+ expect(after.index).not.toBe(before.index);
321+ });
322+323+ // ============================================================
324+ // TEST 3: Direct JS entry point change (about.js โ /about)
325+ // ============================================================
326+ test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => {
327+ let testCounter = 0;
328+329+ function modifyFile(suffix: string) {
330+ testCounter++;
331+ writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`);
332+ }
333+334+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "about");
335+ expect(before.about).not.toBeNull();
336+337+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
338+ await waitForBuildIdChange(htmlPaths.about, before.about);
339+340+ // Verify incremental build with 1 route
341+ expect(isIncrementalBuild(logs)).toBe(true);
342+ expect(getAffectedRouteCount(logs)).toBe(1);
343+344+ // Only about should be rebuilt
345+ const after = recordBuildIds(htmlPaths);
346+ expect(after.index).toBe(before.index);
347+ expect(after.blog).toBe(before.blog);
348+ expect(after.about).not.toBe(before.about);
349+ });
350+351+ // ============================================================
352+ // TEST 4: Shared asset change (styles.css โ / AND /about)
353+ // ============================================================
354+ test("shared asset change rebuilds all routes using it", async ({ devServer }) => {
355+ let testCounter = 0;
356+357+ function modifyFile(suffix: string) {
358+ testCounter++;
359+ writeFileSync(assets.stylesCss, originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`);
360+ }
361+362+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index");
363+ expect(before.index).not.toBeNull();
364+ expect(before.about).not.toBeNull();
365+366+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
367+ await waitForBuildIdChange(htmlPaths.index, before.index);
368+369+ // Verify incremental build with 2 routes (/ and /about both use styles.css)
370+ expect(isIncrementalBuild(logs)).toBe(true);
371+ expect(getAffectedRouteCount(logs)).toBe(2);
372+373+ // Index and about should be rebuilt, blog should not
374+ const after = recordBuildIds(htmlPaths);
375+ expect(after.blog).toBe(before.blog);
376+ expect(after.index).not.toBe(before.index);
377+ expect(after.about).not.toBe(before.about);
378+ });
379+380+ // ============================================================
381+ // TEST 5: Image change (logo.png โ /)
382+ // ============================================================
383+ test("image change rebuilds only routes using it", async ({ devServer }) => {
384+ let testCounter = 0;
385+386+ function modifyFile(suffix: string) {
387+ testCounter++;
388+ const modified = Buffer.concat([
389+ originals.logoPng as Buffer,
390+ Buffer.from(`<!-- test-${testCounter}-${suffix} -->`),
391+ ]);
392+ writeFileSync(assets.logoPng, modified);
393+ }
394+395+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index");
396+ expect(before.index).not.toBeNull();
397+398+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
399+ await waitForBuildIdChange(htmlPaths.index, before.index);
400+401+ // Verify incremental build with 1 route
402+ expect(isIncrementalBuild(logs)).toBe(true);
403+ expect(getAffectedRouteCount(logs)).toBe(1);
404+405+ // Only index should be rebuilt (uses logo.png)
406+ const after = recordBuildIds(htmlPaths);
407+ expect(after.about).toBe(before.about);
408+ expect(after.blog).toBe(before.blog);
409+ expect(after.index).not.toBe(before.index);
410+ });
411+412+ // ============================================================
413+ // TEST 6: Multiple files changed simultaneously
414+ // ============================================================
415+ test("multiple file changes rebuild union of affected routes", async ({ devServer }) => {
416+ let testCounter = 0;
417+418+ function modifyFile(suffix: string) {
419+ testCounter++;
420+ // Change both blog.css (affects /blog) and about.js (affects /about)
421+ writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`);
422+ writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`);
423+ }
424+425+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog");
426+ expect(before.about).not.toBeNull();
427+ expect(before.blog).not.toBeNull();
428+429+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
430+ await waitForBuildIdChange(htmlPaths.blog, before.blog);
431+432+ // Verify incremental build with 2 routes (/about and /blog)
433+ expect(isIncrementalBuild(logs)).toBe(true);
434+ expect(getAffectedRouteCount(logs)).toBe(2);
435+436+ // About and blog should be rebuilt, index should not
437+ const after = recordBuildIds(htmlPaths);
438+ expect(after.index).toBe(before.index);
439+ expect(after.about).not.toBe(before.about);
440+ expect(after.blog).not.toBe(before.blog);
441+ });
442+443+ // ============================================================
444+ // TEST 7: CSS url() asset dependency (bg.png via blog.css โ /blog)
445+ // ============================================================
446+ test("CSS url() asset change triggers rebundling and rebuilds affected routes", async ({
447+ devServer,
448+ }) => {
449+ let testCounter = 0;
450+451+ function modifyFile(suffix: string) {
452+ testCounter++;
453+ const modified = Buffer.concat([
454+ originals.bgPng as Buffer,
455+ Buffer.from(`<!-- test-${testCounter}-${suffix} -->`),
456+ ]);
457+ writeFileSync(assets.bgPng, modified);
458+ }
459+460+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog");
461+ expect(before.blog).not.toBeNull();
462+463+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
464+ await waitForBuildIdChange(htmlPaths.blog, before.blog);
465+466+ // Verify incremental build triggered
467+ expect(isIncrementalBuild(logs)).toBe(true);
468+469+ // Blog should be rebuilt (uses blog.css which references bg.png via url())
470+ const after = recordBuildIds(htmlPaths);
471+ expect(after.blog).not.toBe(before.blog);
472+ });
473+474+ // ============================================================
475+ // TEST 8: Source file change rebuilds only routes defined in that file
476+ // ============================================================
477+ test("source file change rebuilds only routes defined in that file", async ({ devServer }) => {
478+ // This test verifies that when a .rs source file changes, only routes
479+ // defined in that file are rebuilt (via source_to_routes tracking).
480+ //
481+ // Flow:
482+ // 1. Dev server starts โ initial build โ creates build_state.json with source file mappings
483+ // 2. Modify about.rs โ cargo recompiles โ binary reruns with MAUDIT_CHANGED_FILES
484+ // 3. New binary loads build_state.json and finds /about is affected by about.rs
485+ // 4. Only /about route is rebuilt
486+ //
487+ // Note: Unlike asset changes, .rs changes require cargo recompilation.
488+ // The binary's logs (showing "Incremental build") aren't captured by the
489+ // dev server's log collection, so we verify behavior through build IDs.
490+491+ const aboutRs = resolve(fixturePath, "src", "pages", "about.rs");
492+ const originalAboutRs = readFileSync(aboutRs, "utf-8");
493+494+ try {
495+ let testCounter = 0;
496+497+ function modifyFile(suffix: string) {
498+ testCounter++;
499+ writeFileSync(aboutRs, originalAboutRs + `\n// test-${testCounter}-${suffix}`);
500+ }
501+502+ const rsTimeout = 60000;
503+504+ // First change: triggers recompile + build (establishes build state with source_to_routes)
505+ const beforeInit = getBuildId(htmlPaths.about);
506+ await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout);
507+ await waitForBuildIdChange(htmlPaths.about, beforeInit, rsTimeout);
508+509+ // Record build IDs - state now exists with source_to_routes mappings
510+ const before = recordBuildIds(htmlPaths);
511+ expect(before.index).not.toBeNull();
512+ expect(before.about).not.toBeNull();
513+ expect(before.blog).not.toBeNull();
514+515+ // Second change: should do incremental build (only about.rs route)
516+ await triggerAndWaitForBuild(devServer, () => modifyFile("final"), rsTimeout);
517+ await waitForBuildIdChange(htmlPaths.about, before.about, rsTimeout);
518+519+ // Verify only /about was rebuilt (it's defined in about.rs)
520+ const after = recordBuildIds(htmlPaths);
521+ expect(after.index).toBe(before.index);
522+ expect(after.blog).toBe(before.blog);
523+ expect(after.about).not.toBe(before.about);
524+525+ } finally {
526+ // Restore original content and wait for build to complete
527+ const beforeRestore = getBuildId(htmlPaths.about);
528+ writeFileSync(aboutRs, originalAboutRs);
529+ try {
530+ await waitForBuildIdChange(htmlPaths.about, beforeRestore, 60000);
531+ } catch {
532+ // Restoration build may not always complete, that's ok
533+ }
534+ }
535+ });
536+537+ // ============================================================
538+ // TEST 9: include_str! file change triggers full rebuild (untracked file)
539+ // ============================================================
540+ test("include_str file change triggers full rebuild", async ({ devServer }) => {
541+ // This test verifies that changing a file referenced by include_str!()
542+ // triggers cargo recompilation and a FULL rebuild (all routes).
543+ //
544+ // Setup: about.rs uses include_str!("../assets/about-content.txt")
545+ // The .d file from cargo includes this dependency, so the dependency tracker
546+ // knows that changing about-content.txt requires recompilation.
547+ //
548+ // Flow:
549+ // 1. Dev server starts โ initial build
550+ // 2. Modify about-content.txt โ cargo recompiles (because .d file tracks it)
551+ // 3. Binary runs with MAUDIT_CHANGED_FILES pointing to about-content.txt
552+ // 4. Since about-content.txt is NOT in source_to_routes or asset_to_routes,
553+ // it's an "untracked file" and triggers a full rebuild of all routes
554+ //
555+ // This is the correct safe behavior - we don't know which route uses the
556+ // include_str! file, so we rebuild everything to ensure correctness.
557+558+ const contentFile = resolve(fixturePath, "src", "assets", "about-content.txt");
559+ const originalContent = readFileSync(contentFile, "utf-8");
560+ const rsTimeout = 60000;
561+562+ try {
563+ let testCounter = 0;
564+565+ function modifyFile(suffix: string) {
566+ testCounter++;
567+ writeFileSync(contentFile, originalContent + `\n<!-- test-${testCounter}-${suffix} -->`);
568+ }
569+570+ // First change: triggers recompile + full build (establishes build state)
571+ const beforeInit = getBuildId(htmlPaths.about);
572+ await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout);
573+ await waitForBuildIdChange(htmlPaths.about, beforeInit, rsTimeout);
574+575+ // Record build IDs before the final change
576+ const before = recordBuildIds(htmlPaths);
577+ expect(before.index).not.toBeNull();
578+ expect(before.about).not.toBeNull();
579+ expect(before.blog).not.toBeNull();
580+581+ // Trigger the content file change with unique content to verify
582+ devServer.clearLogs();
583+ writeFileSync(contentFile, originalContent + "\nUpdated content!");
584+ await waitForBuildComplete(devServer, rsTimeout);
585+ await waitForBuildIdChange(htmlPaths.about, before.about, rsTimeout);
586+587+ // All routes should be rebuilt (full rebuild due to untracked file)
588+ const after = recordBuildIds(htmlPaths);
589+ expect(after.index).not.toBe(before.index);
590+ expect(after.about).not.toBe(before.about);
591+ expect(after.blog).not.toBe(before.blog);
592+593+ // Verify the content was actually updated in the output
594+ const aboutHtml = readFileSync(htmlPaths.about, "utf-8");
595+ expect(aboutHtml).toContain("Updated content!");
596+597+ } finally {
598+ // Restore original content and wait for build to complete
599+ const beforeRestore = getBuildId(htmlPaths.about);
600+ writeFileSync(contentFile, originalContent);
601+ try {
602+ await waitForBuildIdChange(htmlPaths.about, beforeRestore, 60000);
603+ } catch {
604+ // Restoration build may not always complete, that's ok
605+ }
606+ }
607+ });
608+609+ // ============================================================
610+ // TEST 10: Folder rename detection
611+ // ============================================================
612+ test("folder rename is detected and affects routes using assets in that folder", async ({ devServer }) => {
613+ // This test verifies that renaming a folder containing tracked assets
614+ // is detected by the file watcher and affects the correct routes.
615+ //
616+ // Setup: The blog page uses src/assets/icons/blog-icon.css
617+ // Test: Rename icons -> icons-renamed, verify the blog route is identified as affected
618+ //
619+ // Note: The actual build will fail because the asset path becomes invalid,
620+ // but this test verifies the DETECTION and ROUTE MATCHING works correctly.
621+622+ const iconsFolder = resolve(fixturePath, "src", "assets", "icons");
623+ const renamedFolder = resolve(fixturePath, "src", "assets", "icons-renamed");
624+ const iconFile = resolve(iconsFolder, "blog-icon.css");
625+626+ // Ensure we start with the correct state
627+ if (existsSync(renamedFolder)) {
628+ renameSync(renamedFolder, iconsFolder);
629+ // Wait briefly for any triggered build to start
630+ await new Promise((resolve) => setTimeout(resolve, 500));
631+ }
632+633+ expect(existsSync(iconsFolder)).toBe(true);
634+ expect(existsSync(iconFile)).toBe(true);
635+636+ const originalContent = readFileSync(iconFile, "utf-8");
637+638+ try {
639+ let testCounter = 0;
640+641+ function modifyFile(suffix: string) {
642+ testCounter++;
643+ writeFileSync(iconFile, originalContent + `\n/* test-${testCounter}-${suffix} */`);
644+ }
645+646+ // Use setupIncrementalState to establish tracking
647+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog");
648+ expect(before.blog).not.toBeNull();
649+650+ // Clear logs for the actual test
651+ devServer.clearLogs();
652+653+ // Rename icons -> icons-renamed
654+ renameSync(iconsFolder, renamedFolder);
655+656+ // Wait for the build to be attempted (it will fail because path is now invalid)
657+ const startTime = Date.now();
658+ const timeoutMs = 15000;
659+ let logs: string[] = [];
660+661+ while (Date.now() - startTime < timeoutMs) {
662+ logs = devServer.getLogs(100);
663+ const logsText = logs.join("\n");
664+665+ // Wait for either success or failure indication
666+ if (logsText.includes("finished") || logsText.includes("failed") || logsText.includes("error")) {
667+ break;
668+ }
669+670+ await new Promise((resolve) => setTimeout(resolve, 100));
671+ }
672+673+ logs = devServer.getLogs(100);
674+ const logsText = logs.join("\n");
675+676+ // Key assertions: verify the detection and route matching worked
677+ // 1. The folder paths should be in changed files
678+ expect(logsText).toContain("icons");
679+680+ // 2. The blog route should be identified as affected
681+ expect(logsText).toContain("Rebuilding 1 affected routes");
682+ expect(logsText).toContain("/blog");
683+684+ // 3. Other routes should NOT be affected (index and about don't use icons/)
685+ expect(logsText).not.toContain("/about");
686+687+ } finally {
688+ // Restore: rename icons-renamed back to icons
689+ if (existsSync(renamedFolder) && !existsSync(iconsFolder)) {
690+ renameSync(renamedFolder, iconsFolder);
691+ }
692+ // Restore original content and wait for build
693+ if (existsSync(iconFile)) {
694+ const beforeRestore = getBuildId(htmlPaths.blog);
695+ writeFileSync(iconFile, originalContent);
696+ try {
697+ await waitForBuildIdChange(htmlPaths.blog, beforeRestore, 30000);
698+ } catch {
699+ // Restoration build may not always complete, that's ok
700+ }
701+ }
702+ }
703+ });
704+705+ // ============================================================
706+ // TEST 11: Shared Rust module change triggers full rebuild
707+ // ============================================================
708+ test("shared Rust module change triggers full rebuild", async ({ devServer }) => {
709+ // This test verifies that changing a shared Rust module (not a route file)
710+ // triggers a full rebuild of all routes.
711+ //
712+ // Setup: helpers.rs contains shared functions used by about.rs
713+ // The helpers.rs file is not tracked in source_to_routes (only route files are)
714+ // so it's treated as an "untracked file" which triggers a full rebuild.
715+ //
716+ // This is the correct safe behavior - we can't determine which routes
717+ // depend on the shared module, so we rebuild everything.
718+719+ const helpersRs = resolve(fixturePath, "src", "pages", "helpers.rs");
720+ const originalContent = readFileSync(helpersRs, "utf-8");
721+ const rsTimeout = 60000;
722+723+ try {
724+ let testCounter = 0;
725+726+ function modifyFile(suffix: string) {
727+ testCounter++;
728+ writeFileSync(helpersRs, originalContent + `\n// test-${testCounter}-${suffix}`);
729+ }
730+731+ // First change: triggers recompile + full build (establishes build state)
732+ const beforeInit = getBuildId(htmlPaths.index);
733+ await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout);
734+ await waitForBuildIdChange(htmlPaths.index, beforeInit, rsTimeout);
735+736+ // Record build IDs before the final change
737+ const before = recordBuildIds(htmlPaths);
738+ expect(before.index).not.toBeNull();
739+ expect(before.about).not.toBeNull();
740+ expect(before.blog).not.toBeNull();
741+742+ // Trigger the shared module change
743+ await triggerAndWaitForBuild(devServer, () => modifyFile("final"), rsTimeout);
744+ await waitForBuildIdChange(htmlPaths.index, before.index, rsTimeout);
745+746+ // All routes should be rebuilt (full rebuild due to untracked shared module)
747+ const after = recordBuildIds(htmlPaths);
748+ expect(after.index).not.toBe(before.index);
749+ expect(after.about).not.toBe(before.about);
750+ expect(after.blog).not.toBe(before.blog);
751+752+ } finally {
753+ // Restore original content and wait for build to complete
754+ const beforeRestore = getBuildId(htmlPaths.index);
755+ writeFileSync(helpersRs, originalContent);
756+ try {
757+ await waitForBuildIdChange(htmlPaths.index, beforeRestore, 60000);
758+ } catch {
759+ // Restoration build may not always complete, that's ok
760+ }
761+ }
762+ });
763+764+ // ============================================================
765+ // TEST 12: Content file change rebuilds only routes accessing that specific file
766+ // ============================================================
767+ test("content file change rebuilds only routes accessing that file (granular tracking)", async ({ devServer }) => {
768+ // This test verifies granular content file tracking.
769+ //
770+ // Setup:
771+ // - /articles/first-post uses get_entry("first-post") โ tracks only first-post.md
772+ // - /articles/second-post uses get_entry("second-post") โ tracks only second-post.md
773+ // - /articles (list) uses entries() โ tracks ALL content files
774+ //
775+ // When we change first-post.md:
776+ // - /articles/first-post should be rebuilt (directly uses this file)
777+ // - /articles should be rebuilt (uses entries() which tracks all files)
778+ // - /articles/second-post should NOT be rebuilt (uses different file)
779+ // - /articles/third-post should NOT be rebuilt (uses different file)
780+ // - Other routes (/, /about, /blog) should NOT be rebuilt
781+782+ let testCounter = 0;
783+784+ function modifyFile(suffix: string) {
785+ testCounter++;
786+ const newContent = (originals.firstPost as string).replace(
787+ "first post",
788+ `first post - test-${testCounter}-${suffix}`
789+ );
790+ writeFileSync(contentFiles.firstPost, newContent);
791+ }
792+793+ // Setup: establish incremental state
794+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleFirst");
795+ expect(before.articleFirst).not.toBeNull();
796+ expect(before.articleSecond).not.toBeNull();
797+ expect(before.articles).not.toBeNull();
798+799+ // Trigger the final change
800+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
801+ await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst);
802+803+ // Verify incremental build occurred
804+ expect(isIncrementalBuild(logs)).toBe(true);
805+806+ // Check which routes were rebuilt
807+ const after = recordBuildIds(htmlPaths);
808+809+ // Routes that should NOT be rebuilt (don't access first-post.md)
810+ expect(after.index).toBe(before.index);
811+ expect(after.about).toBe(before.about);
812+ expect(after.blog).toBe(before.blog);
813+ expect(after.articleSecond).toBe(before.articleSecond);
814+ expect(after.articleThird).toBe(before.articleThird);
815+816+ // Routes that SHOULD be rebuilt (access first-post.md)
817+ expect(after.articleFirst).not.toBe(before.articleFirst);
818+ expect(after.articles).not.toBe(before.articles); // Uses entries() which tracks all files
819+ });
820+821+ // ============================================================
822+ // TEST 13: Different content file changes rebuild different routes
823+ // ============================================================
824+ test("different content files trigger rebuilds of different routes", async ({ devServer }) => {
825+ // This test verifies that changing different content files rebuilds
826+ // different sets of routes, proving granular tracking works.
827+ //
828+ // Change second-post.md:
829+ // - /articles/second-post should be rebuilt
830+ // - /articles (list) should be rebuilt (entries() tracks all)
831+ // - /articles/first-post and /articles/third-post should NOT be rebuilt
832+833+ let testCounter = 0;
834+835+ function modifyFile(suffix: string) {
836+ testCounter++;
837+ const newContent = (originals.secondPost as string).replace(
838+ "second post",
839+ `second post - test-${testCounter}-${suffix}`
840+ );
841+ writeFileSync(contentFiles.secondPost, newContent);
842+ }
843+844+ // Setup: establish incremental state
845+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleSecond");
846+ expect(before.articleFirst).not.toBeNull();
847+ expect(before.articleSecond).not.toBeNull();
848+ expect(before.articleThird).not.toBeNull();
849+ expect(before.articles).not.toBeNull();
850+851+ // Trigger the final change
852+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
853+ await waitForBuildIdChange(htmlPaths.articleSecond, before.articleSecond);
854+855+ // Verify incremental build occurred
856+ expect(isIncrementalBuild(logs)).toBe(true);
857+858+ // Check which routes were rebuilt
859+ const after = recordBuildIds(htmlPaths);
860+861+ // Routes that should NOT be rebuilt
862+ expect(after.index).toBe(before.index);
863+ expect(after.about).toBe(before.about);
864+ expect(after.blog).toBe(before.blog);
865+ expect(after.articleFirst).toBe(before.articleFirst);
866+ expect(after.articleThird).toBe(before.articleThird);
867+868+ // Routes that SHOULD be rebuilt
869+ expect(after.articleSecond).not.toBe(before.articleSecond);
870+ expect(after.articles).not.toBe(before.articles);
871+ });
872+873+ // ============================================================
874+ // TEST 14: Multiple content files changed rebuilds union of affected routes
875+ // ============================================================
876+ test("multiple content file changes rebuild union of affected routes", async ({ devServer }) => {
877+ // This test verifies that changing multiple content files correctly
878+ // rebuilds the union of all routes that access any of the changed files.
879+ //
880+ // Change both first-post.md and third-post.md simultaneously:
881+ // - /articles/first-post should be rebuilt
882+ // - /articles/third-post should be rebuilt
883+ // - /articles (list) should be rebuilt
884+ // - /articles/second-post should NOT be rebuilt
885+886+ let testCounter = 0;
887+888+ function modifyFile(suffix: string) {
889+ testCounter++;
890+ // Change both first and third posts
891+ const newFirst = (originals.firstPost as string).replace(
892+ "first post",
893+ `first post - multi-${testCounter}-${suffix}`
894+ );
895+ const newThird = (originals.thirdPost as string).replace(
896+ "third post",
897+ `third post - multi-${testCounter}-${suffix}`
898+ );
899+ writeFileSync(contentFiles.firstPost, newFirst);
900+ writeFileSync(contentFiles.thirdPost, newThird);
901+ }
902+903+ // Setup: establish incremental state
904+ const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleFirst");
905+ expect(before.articleFirst).not.toBeNull();
906+ expect(before.articleSecond).not.toBeNull();
907+ expect(before.articleThird).not.toBeNull();
908+ expect(before.articles).not.toBeNull();
909+910+ // Trigger the final change
911+ const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final"));
912+ await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst);
913+914+ // Verify incremental build occurred
915+ expect(isIncrementalBuild(logs)).toBe(true);
916+917+ // Check which routes were rebuilt
918+ const after = recordBuildIds(htmlPaths);
919+920+ // Routes that should NOT be rebuilt
921+ expect(after.index).toBe(before.index);
922+ expect(after.about).toBe(before.about);
923+ expect(after.blog).toBe(before.blog);
924+ expect(after.articleSecond).toBe(before.articleSecond);
925+926+ // Routes that SHOULD be rebuilt
927+ expect(after.articleFirst).not.toBe(before.articleFirst);
928+ expect(after.articleThird).not.toBe(before.articleThird);
929+ expect(after.articles).not.toBe(before.articles);
930+ });
931+932+ // ============================================================
933+ // TEST 15: Full rebuild from untracked file properly initializes content sources
934+ // ============================================================
935+ test("full rebuild from untracked file properly initializes content sources", async ({ devServer }) => {
936+ // This test verifies that when an untracked Rust file (like helpers.rs) changes,
937+ // triggering a full rebuild (routes_to_rebuild = None), content sources are
938+ // still properly initialized.
939+ //
940+ // This was a bug where the code checked `is_incremental` instead of
941+ // `routes_to_rebuild.is_some()`, causing content sources to not be initialized
942+ // during full rebuilds triggered by untracked file changes.
943+ //
944+ // Setup:
945+ // - helpers.rs is a shared module not tracked in source_to_routes
946+ // - Changing it triggers routes_to_rebuild = None (full rebuild)
947+ // - Routes like /articles/* use content from the "articles" content source
948+ // - If content sources aren't initialized, the build would crash
949+ //
950+ // This test:
951+ // 1. First modifies a content file to ensure specific content exists
952+ // 2. Then modifies helpers.rs to trigger a full rebuild
953+ // 3. Verifies the content-using routes are properly built with correct content
954+955+ const helpersRs = resolve(fixturePath, "src", "pages", "helpers.rs");
956+ const originalHelpersRs = readFileSync(helpersRs, "utf-8");
957+ const rsTimeout = 60000;
958+959+ try {
960+ // Step 1: Modify content file to set up specific content we can verify
961+ const testMarker = `CONTENT-INIT-TEST-${Date.now()}`;
962+ const newContent = (originals.firstPost as string).replace(
963+ "first post",
964+ `first post - ${testMarker}`
965+ );
966+ writeFileSync(contentFiles.firstPost, newContent);
967+968+ // Wait for the content change to be processed
969+ const beforeContent = getBuildId(htmlPaths.articleFirst);
970+ await waitForBuildComplete(devServer, rsTimeout);
971+ await waitForBuildIdChange(htmlPaths.articleFirst, beforeContent, rsTimeout);
972+973+ // Verify the content was updated
974+ let articleHtml = readFileSync(htmlPaths.articleFirst, "utf-8");
975+ expect(articleHtml).toContain(testMarker);
976+977+ // Record build IDs before the helpers.rs change
978+ const before = recordBuildIds(htmlPaths);
979+ expect(before.articleFirst).not.toBeNull();
980+ expect(before.articles).not.toBeNull();
981+982+ // Step 2: Modify helpers.rs to trigger full rebuild
983+ // This is an untracked file, so it triggers routes_to_rebuild = None
984+ devServer.clearLogs();
985+ writeFileSync(helpersRs, originalHelpersRs + `\n// content-init-test-${Date.now()}`);
986+987+ await waitForBuildComplete(devServer, rsTimeout);
988+ await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst, rsTimeout);
989+990+ // Step 3: Verify the build succeeded and content is still correct
991+ // If content sources weren't initialized, this would fail or crash
992+ const after = recordBuildIds(htmlPaths);
993+994+ // All routes should be rebuilt (full rebuild)
995+ expect(after.index).not.toBe(before.index);
996+ expect(after.about).not.toBe(before.about);
997+ expect(after.blog).not.toBe(before.blog);
998+ expect(after.articleFirst).not.toBe(before.articleFirst);
999+ expect(after.articles).not.toBe(before.articles);
1000+1001+ // Most importantly: verify the content-using routes have correct content
1002+ // This proves content sources were properly initialized during the full rebuild
1003+ articleHtml = readFileSync(htmlPaths.articleFirst, "utf-8");
1004+ expect(articleHtml).toContain(testMarker);
1005+1006+ // Also verify the articles list page works (uses entries())
1007+ const articlesHtml = readFileSync(htmlPaths.articles, "utf-8");
1008+ expect(articlesHtml).toContain("First Post");
1009+1010+ } finally {
1011+ // Restore original content
1012+ writeFileSync(helpersRs, originalHelpersRs);
1013+ writeFileSync(contentFiles.firstPost, originals.firstPost as string);
1014+1015+ // Wait for restoration build
1016+ const beforeRestore = getBuildId(htmlPaths.articleFirst);
1017+ try {
1018+ await waitForBuildIdChange(htmlPaths.articleFirst, beforeRestore, 60000);
1019+ } catch {
1020+ // Restoration build may not always complete, that's ok
1021+ }
1022+ }
1023+ });
1024+});
+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 ({
+117-26
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";
···23 port: number;
24 /** Stop the dev server */
25 stop: () => Promise<void>;
000026}
2728/**
···52 const childProcess = spawn(command, args, {
53 cwd: fixturePath,
54 stdio: ["ignore", "pipe", "pipe"],
0000055 });
5657 // Capture output to detect when server is ready
58 let serverReady = false;
05960 const outputPromise = new Promise<number>((resolve, reject) => {
61 const timeout = setTimeout(() => {
62- reject(new Error("Dev server did not start within 30 seconds"));
63- }, 30000);
006465 childProcess.stdout?.on("data", (data: Buffer) => {
66 const output = data.toString();
00000006768 // Look for "waiting for requests" to know server is ready
69 if (output.includes("waiting for requests")) {
···75 });
7677 childProcess.stderr?.on("data", (data: Buffer) => {
78- // Only log errors, not all stderr output
79 const output = data.toString();
00000000080 if (output.toLowerCase().includes("error")) {
81 console.error(`[maudit dev] ${output}`);
82 }
···113 }, 5000);
114 });
115 },
000000000116 };
117}
118···136}
137138// Worker-scoped server pool - one server per worker, shared across all tests in that worker
139-const workerServers = new Map<number, DevServer>();
000000000000000000000000000000000140141-// 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;
00152153- 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";
···23 port: number;
24 /** Stop the dev server */
25 stop: () => Promise<void>;
26+ /** Get recent log output (last N lines) */
27+ getLogs: (lines?: number) => string[];
28+ /** Clear captured logs */
29+ clearLogs: () => void;
30}
3132/**
···56 const childProcess = spawn(command, args, {
57 cwd: fixturePath,
58 stdio: ["ignore", "pipe", "pipe"],
59+ env: {
60+ ...process.env,
61+ // Show binary output for tests so we can verify incremental build logs
62+ MAUDIT_SHOW_BINARY_OUTPUT: "1",
63+ },
64 });
6566 // Capture output to detect when server is ready
67 let serverReady = false;
68+ const capturedLogs: string[] = [];
6970 const outputPromise = new Promise<number>((resolve, reject) => {
71 const timeout = setTimeout(() => {
72+ console.error("[test-utils] Dev server startup timeout. Recent logs:");
73+ console.error(capturedLogs.slice(-20).join("\n"));
74+ reject(new Error("Dev server did not start within 120 seconds"));
75+ }, 120000); // Increased to 120 seconds for CI
7677 childProcess.stdout?.on("data", (data: Buffer) => {
78 const output = data.toString();
79+ // Capture all stdout logs
80+ output
81+ .split("\n")
82+ .filter((line) => line.trim())
83+ .forEach((line) => {
84+ capturedLogs.push(line);
85+ });
8687 // Look for "waiting for requests" to know server is ready
88 if (output.includes("waiting for requests")) {
···94 });
9596 childProcess.stderr?.on("data", (data: Buffer) => {
097 const output = data.toString();
98+ // Capture all stderr logs
99+ output
100+ .split("\n")
101+ .filter((line) => line.trim())
102+ .forEach((line) => {
103+ capturedLogs.push(line);
104+ });
105+106+ // Only log errors to console, not all stderr output
107 if (output.toLowerCase().includes("error")) {
108 console.error(`[maudit dev] ${output}`);
109 }
···140 }, 5000);
141 });
142 },
143+ getLogs: (lines?: number) => {
144+ if (lines) {
145+ return capturedLogs.slice(-lines);
146+ }
147+ return [...capturedLogs];
148+ },
149+ clearLogs: () => {
150+ capturedLogs.length = 0;
151+ },
152 };
153}
154···172}
173174// Worker-scoped server pool - one server per worker, shared across all tests in that worker
175+// Key format: "workerIndex-fixtureName"
176+const workerServers = new Map<string, DevServer>();
177+178+// Track used ports to avoid collisions
179+const usedPorts = new Set<number>();
180+181+/**
182+ * Generate a deterministic port offset based on fixture name.
183+ * This ensures each fixture gets a unique port range, avoiding collisions
184+ * when multiple fixtures run on the same worker.
185+ */
186+function getFixturePortOffset(fixtureName: string): number {
187+ // Simple hash function to get a number from the fixture name
188+ let hash = 0;
189+ for (let i = 0; i < fixtureName.length; i++) {
190+ const char = fixtureName.charCodeAt(i);
191+ hash = (hash << 5) - hash + char;
192+ hash = hash & hash; // Convert to 32bit integer
193+ }
194+ // Use modulo to keep the offset reasonable (0-99)
195+ return Math.abs(hash) % 100;
196+}
197+198+/**
199+ * Find an available port starting from the preferred port.
200+ */
201+function findAvailablePort(preferredPort: number): number {
202+ let port = preferredPort;
203+ while (usedPorts.has(port)) {
204+ port++;
205+ }
206+ usedPorts.add(port);
207+ return port;
208+}
209210+/**
211+ * Create a test instance with a devServer fixture for a specific fixture.
212+ * This allows each test file to use a different fixture while sharing the same pattern.
213+ *
214+ * @param fixtureName - Name of the fixture directory under e2e/fixtures/
215+ * @param basePort - Starting port number (default: 1864). Each fixture gets a unique port based on its name.
216+ *
217+ * @example
218+ * ```ts
219+ * import { createTestWithFixture } from "./test-utils";
220+ * const test = createTestWithFixture("my-fixture");
221+ *
222+ * test("my test", async ({ devServer }) => {
223+ * // devServer is automatically started for "my-fixture"
224+ * });
225+ * ```
226+ */
227+export function createTestWithFixture(fixtureName: string, basePort = 1864) {
228+ return base.extend<{ devServer: DevServer }>({
229+ // oxlint-disable-next-line no-empty-pattern
230+ devServer: async ({}, use, testInfo) => {
231+ // Use worker index to get or create a server for this worker
232+ const workerIndex = testInfo.workerIndex;
233+ const serverKey = `${workerIndex}-${fixtureName}`;
234235+ let server = workerServers.get(serverKey);
236237+ if (!server) {
238+ // Calculate port based on fixture name hash + worker index to avoid collisions
239+ const fixtureOffset = getFixturePortOffset(fixtureName);
240+ const preferredPort = basePort + workerIndex * 100 + fixtureOffset;
241+ const port = findAvailablePort(preferredPort);
242243+ server = await startDevServer({
244+ fixture: fixtureName,
245+ port,
246+ });
247248+ workerServers.set(serverKey, server);
249+ }
250251+ await use(server);
252253+ // Don't stop the server here - it stays alive for all tests in this worker
254+ // Playwright will clean up when the worker exits
255+ },
256+ });
257+}
258259export { 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
···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:
+2-2
website/src/layout/docs_sidebars.rs
···1-use maud::{Markup, html};
2use maudit::{
3 content::MarkdownHeading,
4 route::{PageContext, RouteExt},
···1415 let mut sections = std::collections::HashMap::new();
1617- for entry in content.entries.iter() {
18 if let Some(section) = &entry.data(ctx).section {
19 sections.entry(section).or_insert_with(Vec::new).push(entry);
20 }
···1+use maud::{html, Markup};
2use maudit::{
3 content::MarkdownHeading,
4 route::{PageContext, RouteExt},
···1415 let mut sections = std::collections::HashMap::new();
1617+ for entry in content.entries() {
18 if let Some(section) = &entry.data(ctx).section {
19 sections.entry(section).or_insert_with(Vec::new).push(entry);
20 }
+3-3
website/src/routes/news.rs
···1use chrono::Datelike;
2-use maud::PreEscaped;
3use maud::html;
04use maudit::route::prelude::*;
5use std::collections::BTreeMap;
67use crate::content::NewsContent;
8-use crate::layout::SeoMeta;
9use crate::layout::layout;
01011#[route("/news/")]
12pub struct NewsIndex;
···18 // Group articles by year
19 let mut articles_by_year: BTreeMap<String, Vec<_>> = BTreeMap::new();
2021- for article in &content.entries {
22 let year = article.data(ctx).date.year().to_string();
23 articles_by_year
24 .entry(year)
···1use chrono::Datelike;
02use maud::html;
3+use maud::PreEscaped;
4use maudit::route::prelude::*;
5use std::collections::BTreeMap;
67use crate::content::NewsContent;
08use crate::layout::layout;
9+use crate::layout::SeoMeta;
1011#[route("/news/")]
12pub struct NewsIndex;
···18 // Group articles by year
19 let mut articles_by_year: BTreeMap<String, Vec<_>> = BTreeMap::new();
2021+ for article in content.entries() {
22 let year = article.data(ctx).date.year().to_string();
23 articles_by_year
24 .entry(year)
+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"] }