Rust library to generate static websites

feat: smoother hot reload #1

open opened by erika.florist targeting main from feat/better-hotreload
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:qdu4g4n4eniupf5jf5yfsxo3/sh.tangled.repo.pull/3mdq52ot4wm22
+2966 -443
Diff #0
+1 -1
.github/workflows/benchmark.yaml
··· 41 uses: actions/setup-node@v4 42 with: 43 node-version: latest 44 - cache: 'pnpm' 45 46 - name: Install dependencies 47 run: pnpm install
··· 41 uses: actions/setup-node@v4 42 with: 43 node-version: latest 44 + cache: "pnpm" 45 46 - name: Install dependencies 47 run: pnpm install
+4 -4
.github/workflows/ci.yaml
··· 38 uses: actions/setup-node@v4 39 with: 40 node-version: latest 41 - cache: 'pnpm' 42 43 - name: Install dependencies 44 run: pnpm install ··· 66 uses: actions/setup-node@v4 67 with: 68 node-version: latest 69 - cache: 'pnpm' 70 71 - name: Install dependencies 72 run: pnpm install ··· 94 uses: actions/setup-node@v4 95 with: 96 node-version: latest 97 - cache: 'pnpm' 98 99 - name: Install dependencies 100 run: pnpm install ··· 126 uses: actions/setup-node@v4 127 with: 128 node-version: latest 129 - cache: 'pnpm' 130 131 - name: Install dependencies 132 run: pnpm install
··· 38 uses: actions/setup-node@v4 39 with: 40 node-version: latest 41 + cache: "pnpm" 42 43 - name: Install dependencies 44 run: pnpm install ··· 66 uses: actions/setup-node@v4 67 with: 68 node-version: latest 69 + cache: "pnpm" 70 71 - name: Install dependencies 72 run: pnpm install ··· 94 uses: actions/setup-node@v4 95 with: 96 node-version: latest 97 + cache: "pnpm" 98 99 - name: Install dependencies 100 run: pnpm install ··· 126 uses: actions/setup-node@v4 127 with: 128 node-version: latest 129 + cache: "pnpm" 130 131 - name: Install dependencies 132 run: pnpm install
+1 -1
.github/workflows/release.yml
··· 30 uses: actions/setup-node@v4 31 with: 32 node-version: latest 33 - cache: 'pnpm' 34 35 - name: Install dependencies 36 run: pnpm install
··· 30 uses: actions/setup-node@v4 31 with: 32 node-version: latest 33 + cache: "pnpm" 34 35 - name: Install dependencies 36 run: pnpm install
+2 -6
.vscode/extensions.json
··· 1 { 2 - "recommendations": [ 3 - "oxc.oxc-vscode", 4 - "TypeScriptTeam.native-preview", 5 - "rust-lang.rust-analyzer" 6 - ] 7 - }
··· 1 { 2 + "recommendations": ["oxc.oxc-vscode", "TypeScriptTeam.native-preview", "rust-lang.rust-analyzer"] 3 + }
+14 -14
.vscode/settings.json
··· 1 { 2 - "typescript.experimental.useTsgo": true, 3 - "editor.defaultFormatter": "oxc.oxc-vscode", 4 - "oxc.typeAware": true, 5 - "oxc.fixKind": "safe_fix", 6 - "oxc.unusedDisableDirectives": "deny", 7 - "[rust]": { 8 - "editor.defaultFormatter": "rust-lang.rust-analyzer" 9 - }, 10 - "editor.codeActionsOnSave": { 11 - "source.fixAll.oxc": "explicit" 12 - }, 13 - "biome.enabled": false, 14 - "css.lint.unknownAtRules": "ignore", 15 - }
··· 1 { 2 + "typescript.experimental.useTsgo": true, 3 + "editor.defaultFormatter": "oxc.oxc-vscode", 4 + "oxc.typeAware": true, 5 + "oxc.fixKind": "safe_fix", 6 + "oxc.unusedDisableDirectives": "deny", 7 + "[rust]": { 8 + "editor.defaultFormatter": "rust-lang.rust-analyzer" 9 + }, 10 + "editor.codeActionsOnSave": { 11 + "source.fixAll.oxc": "explicit" 12 + }, 13 + "biome.enabled": false, 14 + "css.lint.unknownAtRules": "ignore" 15 + }
+64 -2
Cargo.lock
··· 1670 ] 1671 1672 [[package]] 1673 name = "fixtures-prefetch-prerender" 1674 version = "0.1.0" 1675 dependencies = [ ··· 2574 dependencies = [ 2575 "base64", 2576 "brk_rolldown", 2577 "brk_rolldown_plugin_replace", 2578 "chrono", 2579 "colored 3.1.1", ··· 2592 "rayon", 2593 "rustc-hash", 2594 "serde", 2595 "serde_yaml", 2596 "slug", 2597 "syntect", ··· 2622 "serde_json", 2623 "spinach", 2624 "tar", 2625 "tokio", 2626 "tokio-util", 2627 "toml_edit 0.24.0+spec-1.1.0", 2628 "tower-http", 2629 "tracing", ··· 4522 ] 4523 4524 [[package]] 4525 name = "serde_urlencoded" 4526 version = "0.7.1" 4527 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5019 ] 5020 5021 [[package]] 5022 name = "toml_datetime" 5023 version = "0.7.5+spec-1.1.0" 5024 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5029 5030 [[package]] 5031 name = "toml_edit" 5032 version = "0.23.10+spec-1.0.0" 5033 source = "registry+https://github.com/rust-lang/crates.io-index" 5034 checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" 5035 dependencies = [ 5036 "indexmap", 5037 - "toml_datetime", 5038 "toml_parser", 5039 "winnow", 5040 ] ··· 5046 checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" 5047 dependencies = [ 5048 "indexmap", 5049 - "toml_datetime", 5050 "toml_parser", 5051 "toml_writer", 5052 "winnow", ··· 5060 dependencies = [ 5061 "winnow", 5062 ] 5063 5064 [[package]] 5065 name = "toml_writer"
··· 1670 ] 1671 1672 [[package]] 1673 + name = "fixtures-incremental-build" 1674 + version = "0.1.0" 1675 + dependencies = [ 1676 + "maud", 1677 + "maudit", 1678 + ] 1679 + 1680 + [[package]] 1681 name = "fixtures-prefetch-prerender" 1682 version = "0.1.0" 1683 dependencies = [ ··· 2582 dependencies = [ 2583 "base64", 2584 "brk_rolldown", 2585 + "brk_rolldown_common", 2586 "brk_rolldown_plugin_replace", 2587 "chrono", 2588 "colored 3.1.1", ··· 2601 "rayon", 2602 "rustc-hash", 2603 "serde", 2604 + "serde_json", 2605 "serde_yaml", 2606 "slug", 2607 "syntect", ··· 2632 "serde_json", 2633 "spinach", 2634 "tar", 2635 + "tempfile", 2636 "tokio", 2637 "tokio-util", 2638 + "toml", 2639 "toml_edit 0.24.0+spec-1.1.0", 2640 "tower-http", 2641 "tracing", ··· 4534 ] 4535 4536 [[package]] 4537 + name = "serde_spanned" 4538 + version = "0.6.9" 4539 + source = "registry+https://github.com/rust-lang/crates.io-index" 4540 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 4541 + dependencies = [ 4542 + "serde", 4543 + ] 4544 + 4545 + [[package]] 4546 name = "serde_urlencoded" 4547 version = "0.7.1" 4548 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5040 ] 5041 5042 [[package]] 5043 + name = "toml" 5044 + version = "0.8.23" 5045 + source = "registry+https://github.com/rust-lang/crates.io-index" 5046 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 5047 + dependencies = [ 5048 + "serde", 5049 + "serde_spanned", 5050 + "toml_datetime 0.6.11", 5051 + "toml_edit 0.22.27", 5052 + ] 5053 + 5054 + [[package]] 5055 + name = "toml_datetime" 5056 + version = "0.6.11" 5057 + source = "registry+https://github.com/rust-lang/crates.io-index" 5058 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 5059 + dependencies = [ 5060 + "serde", 5061 + ] 5062 + 5063 + [[package]] 5064 name = "toml_datetime" 5065 version = "0.7.5+spec-1.1.0" 5066 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5071 5072 [[package]] 5073 name = "toml_edit" 5074 + version = "0.22.27" 5075 + source = "registry+https://github.com/rust-lang/crates.io-index" 5076 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 5077 + dependencies = [ 5078 + "indexmap", 5079 + "serde", 5080 + "serde_spanned", 5081 + "toml_datetime 0.6.11", 5082 + "toml_write", 5083 + "winnow", 5084 + ] 5085 + 5086 + [[package]] 5087 + name = "toml_edit" 5088 version = "0.23.10+spec-1.0.0" 5089 source = "registry+https://github.com/rust-lang/crates.io-index" 5090 checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" 5091 dependencies = [ 5092 "indexmap", 5093 + "toml_datetime 0.7.5+spec-1.1.0", 5094 "toml_parser", 5095 "winnow", 5096 ] ··· 5102 checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" 5103 dependencies = [ 5104 "indexmap", 5105 + "toml_datetime 0.7.5+spec-1.1.0", 5106 "toml_parser", 5107 "toml_writer", 5108 "winnow", ··· 5116 dependencies = [ 5117 "winnow", 5118 ] 5119 + 5120 + [[package]] 5121 + name = "toml_write" 5122 + version = "0.1.2" 5123 + source = "registry+https://github.com/rust-lang/crates.io-index" 5124 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 5125 5126 [[package]] 5127 name = "toml_writer"
+3 -1
crates/maudit/Cargo.toml
··· 23 24 # TODO: Allow making those optional 25 rolldown = { package = "brk_rolldown", version = "0.8.0" } 26 serde = { workspace = true } 27 serde_yaml = "0.9.34" 28 pulldown-cmark = "0.13.0" 29 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ··· 48 rayon = "1.11.0" 49 rapidhash = "4.2.1" 50 pathdiff = "0.2.3" 51 - rolldown_plugin_replace = {package = "brk_rolldown_plugin_replace", version = "0.8.0"} 52 53 [dev-dependencies] 54 tempfile = "3.24.0"
··· 23 24 # TODO: Allow making those optional 25 rolldown = { package = "brk_rolldown", version = "0.8.0" } 26 + rolldown_common = { package = "brk_rolldown_common", version = "0.8.0" } 27 serde = { workspace = true } 28 + serde_json = "1.0" 29 serde_yaml = "0.9.34" 30 pulldown-cmark = "0.13.0" 31 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ··· 50 rayon = "1.11.0" 51 rapidhash = "4.2.1" 52 pathdiff = "0.2.3" 53 + rolldown_plugin_replace = { package = "brk_rolldown_plugin_replace", version = "0.8.0" } 54 55 [dev-dependencies] 56 tempfile = "3.24.0"
+4 -8
crates/maudit/src/assets/image_cache.rs
··· 338 339 #[test] 340 fn test_build_options_integration() { 341 - use crate::build::options::{AssetsOptions, BuildOptions}; 342 343 // 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 }; 352 353 - // Create cache with build options 354 - let cache = ImageCache::with_cache_dir(&build_options.assets.image_cache_dir); 355 356 // Verify it uses the configured directory 357 - assert_eq!(cache.get_cache_dir(), custom_cache); 358 } 359 360 #[test]
··· 338 339 #[test] 340 fn test_build_options_integration() { 341 + use crate::build::options::BuildOptions; 342 343 // 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(), 347 ..Default::default() 348 }; 349 350 + let cache = ImageCache::with_cache_dir(build_options.assets_cache_dir()); 351 352 // Verify it uses the configured directory 353 + assert_eq!(cache.get_cache_dir(), custom_cache.join("assets")); 354 } 355 356 #[test]
+104 -13
crates/maudit/src/build/options.rs
··· 1 - use std::{env, path::PathBuf}; 2 3 use 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, 63 64 pub assets: AssetsOptions, 65 66 pub prefetch: PrefetchOptions, ··· 124 hashing_strategy: self.assets.hashing_strategy, 125 } 126 } 127 } 128 129 #[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, 141 142 - /// 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 /// ``` 197 impl Default for BuildOptions { 198 fn default() -> Self { 199 Self { 200 base_url: None, 201 output_dir: "dist".into(), 202 static_dir: "static".into(), 203 clean_output_dir: true, 204 prefetch: PrefetchOptions::default(), 205 assets: AssetsOptions::default(), 206 sitemap: SitemapOptions::default(), 207 } 208 } 209 }
··· 1 + use std::{fs, path::PathBuf}; 2 3 use 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 /// ..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, 62 63 + /// 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, 84 85 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 } 155 156 #[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, 168 169 /// 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(), 188 hashing_strategy: if is_dev() { 189 AssetHashingStrategy::FastImprecise 190 } else { ··· 212 /// ``` 213 impl 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 + }
+233
crates/maudit/src/build/state.rs
···
··· 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 + /// Stores all bundler input paths from the last build 62 + /// This needs to be preserved to ensure consistent bundling 63 + pub bundler_inputs: Vec<String>, 64 + } 65 + 66 + impl BuildState { 67 + pub fn new() -> Self { 68 + Self::default() 69 + } 70 + 71 + /// Load build state from disk cache 72 + pub fn load(cache_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> { 73 + let state_path = cache_dir.join("build_state.json"); 74 + 75 + if !state_path.exists() { 76 + return Ok(Self::new()); 77 + } 78 + 79 + let content = fs::read_to_string(&state_path)?; 80 + let state: BuildState = serde_json::from_str(&content)?; 81 + Ok(state) 82 + } 83 + 84 + /// Save build state to disk cache 85 + pub fn save(&self, cache_dir: &Path) -> Result<(), Box<dyn std::error::Error>> { 86 + fs::create_dir_all(cache_dir)?; 87 + let state_path = cache_dir.join("build_state.json"); 88 + let content = serde_json::to_string_pretty(self)?; 89 + fs::write(state_path, content)?; 90 + Ok(()) 91 + } 92 + 93 + /// Add an asset->route mapping 94 + pub fn track_asset(&mut self, asset_path: PathBuf, route_id: RouteIdentifier) { 95 + self.asset_to_routes 96 + .entry(asset_path) 97 + .or_default() 98 + .insert(route_id); 99 + } 100 + 101 + /// Get all routes affected by changes to specific files 102 + pub fn get_affected_routes(&self, changed_files: &[PathBuf]) -> FxHashSet<RouteIdentifier> { 103 + let mut affected_routes = FxHashSet::default(); 104 + 105 + for changed_file in changed_files { 106 + // Canonicalize the changed file path for consistent comparison 107 + // All asset paths in asset_to_routes are stored as canonical paths 108 + let canonical_changed = changed_file.canonicalize().ok(); 109 + 110 + // Try exact match with canonical path 111 + if let Some(canonical) = &canonical_changed 112 + && let Some(routes) = self.asset_to_routes.get(canonical) 113 + { 114 + affected_routes.extend(routes.iter().cloned()); 115 + continue; // Found exact match, no need for directory check 116 + } 117 + 118 + // Fallback: try exact match with original path (shouldn't normally match) 119 + if let Some(routes) = self.asset_to_routes.get(changed_file) { 120 + affected_routes.extend(routes.iter().cloned()); 121 + continue; 122 + } 123 + 124 + // Directory prefix check: find all routes using assets within this directory. 125 + // This handles two cases: 126 + // 1. A directory was modified - rebuild all routes using assets in that dir 127 + // 2. A directory was renamed/deleted - the old path no longer exists but we 128 + // still need to rebuild routes that used assets under that path 129 + // 130 + // We do this check if: 131 + // - The path currently exists as a directory, OR 132 + // - The path doesn't exist (could be a deleted/renamed directory) 133 + let should_check_prefix = changed_file.is_dir() || !changed_file.exists(); 134 + 135 + if should_check_prefix { 136 + // Use original path for prefix matching (canonical won't exist for deleted dirs) 137 + for (asset_path, routes) in &self.asset_to_routes { 138 + if asset_path.starts_with(changed_file) { 139 + affected_routes.extend(routes.iter().cloned()); 140 + } 141 + } 142 + } 143 + } 144 + 145 + affected_routes 146 + } 147 + 148 + /// Clear all tracked data (for full rebuild) 149 + pub fn clear(&mut self) { 150 + self.asset_to_routes.clear(); 151 + self.bundler_inputs.clear(); 152 + } 153 + } 154 + 155 + #[cfg(test)] 156 + mod tests { 157 + use super::*; 158 + 159 + fn make_route(path: &str) -> RouteIdentifier { 160 + RouteIdentifier::base(path.to_string(), None) 161 + } 162 + 163 + #[test] 164 + fn test_get_affected_routes_exact_match() { 165 + let mut state = BuildState::new(); 166 + let asset_path = PathBuf::from("/project/src/assets/logo.png"); 167 + let route = make_route("/"); 168 + 169 + state.track_asset(asset_path.clone(), route.clone()); 170 + 171 + // Exact match should work 172 + let affected = state.get_affected_routes(&[asset_path]); 173 + assert_eq!(affected.len(), 1); 174 + assert!(affected.contains(&route)); 175 + } 176 + 177 + #[test] 178 + fn test_get_affected_routes_no_match() { 179 + let mut state = BuildState::new(); 180 + let asset_path = PathBuf::from("/project/src/assets/logo.png"); 181 + let route = make_route("/"); 182 + 183 + state.track_asset(asset_path, route); 184 + 185 + // Different file should not match 186 + let other_path = PathBuf::from("/project/src/assets/other.png"); 187 + let affected = state.get_affected_routes(&[other_path]); 188 + assert!(affected.is_empty()); 189 + } 190 + 191 + #[test] 192 + fn test_get_affected_routes_deleted_directory() { 193 + let mut state = BuildState::new(); 194 + 195 + // Track assets under a directory path 196 + let asset1 = PathBuf::from("/project/src/assets/icons/logo.png"); 197 + let asset2 = PathBuf::from("/project/src/assets/icons/favicon.ico"); 198 + let asset3 = PathBuf::from("/project/src/assets/styles.css"); 199 + let route1 = make_route("/"); 200 + let route2 = make_route("/about"); 201 + 202 + state.track_asset(asset1, route1.clone()); 203 + state.track_asset(asset2, route1.clone()); 204 + state.track_asset(asset3, route2.clone()); 205 + 206 + // Simulate a deleted/renamed directory (path doesn't exist) 207 + // The "icons" directory was renamed, so the old path doesn't exist 208 + let deleted_dir = PathBuf::from("/project/src/assets/icons"); 209 + 210 + // Since the path doesn't exist, it should check prefix matching 211 + let affected = state.get_affected_routes(&[deleted_dir]); 212 + 213 + // Should find route1 (uses assets under /icons/) but not route2 214 + assert_eq!(affected.len(), 1); 215 + assert!(affected.contains(&route1)); 216 + } 217 + 218 + #[test] 219 + fn test_get_affected_routes_multiple_routes_same_asset() { 220 + let mut state = BuildState::new(); 221 + let asset_path = PathBuf::from("/project/src/assets/shared.css"); 222 + let route1 = make_route("/"); 223 + let route2 = make_route("/about"); 224 + 225 + state.track_asset(asset_path.clone(), route1.clone()); 226 + state.track_asset(asset_path.clone(), route2.clone()); 227 + 228 + let affected = state.get_affected_routes(&[asset_path]); 229 + assert_eq!(affected.len(), 2); 230 + assert!(affected.contains(&route1)); 231 + assert!(affected.contains(&route2)); 232 + } 233 + }
+457 -137
crates/maudit/src/build.rs
··· 14 self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin, 15 calculate_hash, image_cache::ImageCache, prefetch, 16 }, 17 - build::{images::process_image, options::PrefetchStrategy}, 18 content::ContentSources, 19 is_dev, 20 logging::print_title, ··· 26 use log::{debug, info, trace, warn}; 27 use pathdiff::diff_paths; 28 use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType}; 29 use rolldown_plugin_replace::ReplacePlugin; 30 use rustc_hash::{FxHashMap, FxHashSet}; 31 ··· 36 pub mod images; 37 pub mod metadata; 38 pub mod options; 39 40 pub fn execute_build( 41 routes: &[&dyn FullRoute], 42 content_sources: &mut ContentSources, 43 options: &BuildOptions, 44 async_runtime: &tokio::runtime::Runtime, 45 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 46 - async_runtime.block_on(async { build(routes, content_sources, options).await }) 47 } 48 49 pub async fn build( 50 routes: &[&dyn FullRoute], 51 content_sources: &mut ContentSources, 52 options: &BuildOptions, 53 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 54 let build_start = Instant::now(); 55 let mut build_metadata = BuildOutput::new(build_start); ··· 57 // Create a directory for the output 58 trace!(target: "build", "Setting up required directories..."); 59 60 - let clean_up_handle = if options.clean_output_dir { 61 let old_dist_tmp_dir = { 62 let duration = SystemTime::now().duration_since(UNIX_EPOCH)?; 63 let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000; ··· 74 }; 75 76 // Create the image cache early so it can be shared across routes 77 - let image_cache = ImageCache::with_cache_dir(&options.assets.image_cache_dir); 78 let _ = fs::create_dir_all(image_cache.get_cache_dir()); 79 80 // Create route_assets_options with the image cache ··· 183 184 // Static base route 185 if base_params.is_empty() { 186 - let mut route_assets = RouteAssets::with_default_assets( 187 - &route_assets_options, 188 - Some(image_cache.clone()), 189 - default_scripts.clone(), 190 - vec![], 191 - ); 192 193 - let params = PageParams::default(); 194 - let url = cached_route.url(&params); 195 196 - let result = route.build(&mut PageContext::from_static_route( 197 - content_sources, 198 - &mut route_assets, 199 - &url, 200 - &options.base_url, 201 - None, 202 - ))?; 203 204 - let file_path = cached_route.file_path(&params, &options.output_dir); 205 206 - write_route_file(&result, &file_path)?; 207 208 - info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 209 210 - build_pages_images.extend(route_assets.images); 211 - build_pages_scripts.extend(route_assets.scripts); 212 - build_pages_styles.extend(route_assets.styles); 213 214 - build_metadata.add_page( 215 - base_path.clone(), 216 - file_path.to_string_lossy().to_string(), 217 - None, 218 - ); 219 220 - add_sitemap_entry( 221 - &mut sitemap_entries, 222 - normalized_base_url, 223 - &url, 224 - base_path, 225 - &route.sitemap_metadata(), 226 - &options.sitemap, 227 - ); 228 229 - page_count += 1; 230 } else { 231 // Dynamic base route 232 let mut route_assets = RouteAssets::with_default_assets( ··· 250 251 // Build all pages for this route 252 for page in pages { 253 - let page_start = Instant::now(); 254 - let url = cached_route.url(&page.0); 255 - let file_path = cached_route.file_path(&page.0, &options.output_dir); 256 257 - let content = route.build(&mut PageContext::from_dynamic_route( 258 - &page, 259 - content_sources, 260 - &mut route_assets, 261 - &url, 262 - &options.base_url, 263 - None, 264 - ))?; 265 266 - write_route_file(&content, &file_path)?; 267 268 - info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options)); 269 270 - build_metadata.add_page( 271 - base_path.clone(), 272 - file_path.to_string_lossy().to_string(), 273 - Some(page.0.0.clone()), 274 - ); 275 276 - add_sitemap_entry( 277 - &mut sitemap_entries, 278 - normalized_base_url, 279 - &url, 280 - base_path, 281 - &route.sitemap_metadata(), 282 - &options.sitemap, 283 - ); 284 285 - page_count += 1; 286 } 287 } 288 ··· 299 300 if variant_params.is_empty() { 301 // Static variant 302 - let mut route_assets = RouteAssets::with_default_assets( 303 - &route_assets_options, 304 - Some(image_cache.clone()), 305 - default_scripts.clone(), 306 - vec![], 307 - ); 308 309 - let params = PageParams::default(); 310 - let url = cached_route.variant_url(&params, &variant_id)?; 311 - let file_path = 312 - cached_route.variant_file_path(&params, &options.output_dir, &variant_id)?; 313 314 - let result = route.build(&mut PageContext::from_static_route( 315 - content_sources, 316 - &mut route_assets, 317 - &url, 318 - &options.base_url, 319 - Some(variant_id.clone()), 320 - ))?; 321 322 - write_route_file(&result, &file_path)?; 323 324 - info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options)); 325 326 - build_pages_images.extend(route_assets.images); 327 - build_pages_scripts.extend(route_assets.scripts); 328 - build_pages_styles.extend(route_assets.styles); 329 330 - build_metadata.add_page( 331 - variant_path.clone(), 332 - file_path.to_string_lossy().to_string(), 333 - None, 334 - ); 335 336 - add_sitemap_entry( 337 - &mut sitemap_entries, 338 - normalized_base_url, 339 - &url, 340 - &variant_path, 341 - &route.sitemap_metadata(), 342 - &options.sitemap, 343 - ); 344 345 - page_count += 1; 346 } else { 347 // Dynamic variant 348 let mut route_assets = RouteAssets::with_default_assets( ··· 365 366 // Build all pages for this variant group 367 for page in pages { 368 - let variant_page_start = Instant::now(); 369 - let url = cached_route.variant_url(&page.0, &variant_id)?; 370 - let file_path = cached_route.variant_file_path( 371 - &page.0, 372 - &options.output_dir, 373 - &variant_id, 374 - )?; 375 376 - let content = route.build(&mut PageContext::from_dynamic_route( 377 - &page, 378 - content_sources, 379 - &mut route_assets, 380 - &url, 381 - &options.base_url, 382 - Some(variant_id.clone()), 383 - ))?; 384 385 - write_route_file(&content, &file_path)?; 386 387 - info!(target: "pages", "โ”‚ โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options)); 388 389 - build_metadata.add_page( 390 - variant_path.clone(), 391 - file_path.to_string_lossy().to_string(), 392 - Some(page.0.0.clone()), 393 - ); 394 395 - add_sitemap_entry( 396 - &mut sitemap_entries, 397 - normalized_base_url, 398 - &url, 399 - &variant_path, 400 - &route.sitemap_metadata(), 401 - &options.sitemap, 402 - ); 403 404 - page_count += 1; 405 } 406 } 407 ··· 421 fs::create_dir_all(&route_assets_options.output_assets_dir)?; 422 } 423 424 - if !build_pages_styles.is_empty() || !build_pages_scripts.is_empty() { 425 let assets_start = Instant::now(); 426 print_title("generating assets"); 427 ··· 439 }) 440 .collect::<Vec<InputItem>>(); 441 442 - let bundler_inputs = build_pages_scripts 443 .iter() 444 .map(|script| InputItem { 445 import: script.path().to_string_lossy().to_string(), ··· 454 .chain(css_inputs.into_iter()) 455 .collect::<Vec<InputItem>>(); 456 457 debug!( 458 target: "bundling", 459 "Bundler inputs: {:?}", ··· 463 .collect::<Vec<String>>() 464 ); 465 466 if !bundler_inputs.is_empty() { 467 let mut module_types_hashmap = FxHashMap::default(); 468 module_types_hashmap.insert("woff".to_string(), ModuleType::Asset); 469 module_types_hashmap.insert("woff2".to_string(), ModuleType::Asset); 470 471 let mut bundler = Bundler::with_plugins( 472 BundlerOptions { ··· 500 ], 501 )?; 502 503 - let _result = bundler.write().await?; 504 505 - // TODO: Add outputted chunks to build_metadata 506 } 507 508 info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), &section_format_options)).bold()); ··· 598 info!(target: "SKIP_FORMAT", "{}", ""); 599 info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), &section_format_options)).bold()); 600 601 if let Some(clean_up_handle) = clean_up_handle { 602 clean_up_handle.await?; 603 } ··· 680 fs::create_dir_all(parent_dir)? 681 } 682 683 fs::write(file_path, content)?; 684 685 Ok(())
··· 14 self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin, 15 calculate_hash, image_cache::ImageCache, prefetch, 16 }, 17 + build::{ 18 + images::process_image, 19 + options::PrefetchStrategy, 20 + state::{BuildState, RouteIdentifier}, 21 + }, 22 content::ContentSources, 23 is_dev, 24 logging::print_title, ··· 30 use log::{debug, info, trace, warn}; 31 use pathdiff::diff_paths; 32 use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType}; 33 + use rolldown_common::Output; 34 use rolldown_plugin_replace::ReplacePlugin; 35 use rustc_hash::{FxHashMap, FxHashSet}; 36 ··· 41 pub mod images; 42 pub mod metadata; 43 pub mod options; 44 + pub mod state; 45 + 46 + /// Helper to check if a route should be rebuilt during incremental builds 47 + fn should_rebuild_route( 48 + route_id: &RouteIdentifier, 49 + routes_to_rebuild: &Option<FxHashSet<RouteIdentifier>>, 50 + ) -> bool { 51 + let result = match routes_to_rebuild { 52 + Some(set) => set.contains(route_id), 53 + None => true, // Full build 54 + }; 55 + 56 + if !result { 57 + trace!(target: "build", "Skipping route {:?} (not in rebuild set)", route_id); 58 + } 59 + 60 + result 61 + } 62 + 63 + /// Helper to track all assets used by a route 64 + fn track_route_assets( 65 + build_state: &mut BuildState, 66 + route_id: &RouteIdentifier, 67 + route_assets: &RouteAssets, 68 + ) { 69 + // Track images 70 + for image in &route_assets.images { 71 + if let Ok(canonical) = image.path().canonicalize() { 72 + build_state.track_asset(canonical, route_id.clone()); 73 + } 74 + } 75 + 76 + // Track scripts 77 + for script in &route_assets.scripts { 78 + if let Ok(canonical) = script.path().canonicalize() { 79 + build_state.track_asset(canonical, route_id.clone()); 80 + } 81 + } 82 + 83 + // Track styles 84 + for style in &route_assets.styles { 85 + if let Ok(canonical) = style.path().canonicalize() { 86 + build_state.track_asset(canonical, route_id.clone()); 87 + } 88 + } 89 + } 90 91 pub fn execute_build( 92 routes: &[&dyn FullRoute], 93 content_sources: &mut ContentSources, 94 options: &BuildOptions, 95 + changed_files: Option<&[PathBuf]>, 96 async_runtime: &tokio::runtime::Runtime, 97 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 98 + async_runtime.block_on(async { build(routes, content_sources, options, changed_files).await }) 99 } 100 101 pub async fn build( 102 routes: &[&dyn FullRoute], 103 content_sources: &mut ContentSources, 104 options: &BuildOptions, 105 + changed_files: Option<&[PathBuf]>, 106 ) -> Result<BuildOutput, Box<dyn std::error::Error>> { 107 let build_start = Instant::now(); 108 let mut build_metadata = BuildOutput::new(build_start); ··· 110 // Create a directory for the output 111 trace!(target: "build", "Setting up required directories..."); 112 113 + // Use cache directory from options 114 + let build_cache_dir = &options.cache_dir; 115 + 116 + // Load build state for incremental builds (only if incremental is enabled) 117 + let mut build_state = if options.incremental { 118 + BuildState::load(build_cache_dir).unwrap_or_else(|e| { 119 + debug!(target: "build", "Failed to load build state: {}", e); 120 + BuildState::new() 121 + }) 122 + } else { 123 + BuildState::new() 124 + }; 125 + 126 + debug!(target: "build", "Loaded build state with {} asset mappings", build_state.asset_to_routes.len()); 127 + debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some()); 128 + 129 + // Determine if this is an incremental build 130 + let is_incremental = 131 + options.incremental && changed_files.is_some() && !build_state.asset_to_routes.is_empty(); 132 + 133 + let routes_to_rebuild = if is_incremental { 134 + let changed = changed_files.unwrap(); 135 + info!(target: "build", "Incremental build: {} files changed", changed.len()); 136 + info!(target: "build", "Changed files: {:?}", changed); 137 + 138 + info!(target: "build", "Build state has {} asset mappings", build_state.asset_to_routes.len()); 139 + 140 + let affected = build_state.get_affected_routes(changed); 141 + info!(target: "build", "Rebuilding {} affected routes", affected.len()); 142 + info!(target: "build", "Affected routes: {:?}", affected); 143 + 144 + Some(affected) 145 + } else { 146 + if changed_files.is_some() { 147 + info!(target: "build", "Full build (first run after recompilation)"); 148 + } 149 + // Full build - clear old state 150 + build_state.clear(); 151 + None 152 + }; 153 + 154 + // Check if we should rebundle during incremental builds 155 + // Rebundle if a changed file is either: 156 + // 1. A direct bundler input (entry point) 157 + // 2. A transitive dependency tracked in asset_to_routes (any file the bundler processed) 158 + let should_rebundle = if is_incremental && !build_state.bundler_inputs.is_empty() { 159 + let changed = changed_files.unwrap(); 160 + let should = changed.iter().any(|changed_file| { 161 + // Check if it's a direct bundler input 162 + let is_bundler_input = build_state.bundler_inputs.iter().any(|bundler_input| { 163 + if let (Ok(changed_canonical), Ok(bundler_canonical)) = ( 164 + changed_file.canonicalize(), 165 + PathBuf::from(bundler_input).canonicalize(), 166 + ) { 167 + changed_canonical == bundler_canonical 168 + } else { 169 + false 170 + } 171 + }); 172 + 173 + if is_bundler_input { 174 + return true; 175 + } 176 + 177 + // Check if it's a transitive dependency tracked by the bundler 178 + // (JS/TS modules, CSS files, or assets like images/fonts referenced via url()) 179 + if let Ok(canonical) = changed_file.canonicalize() { 180 + return build_state.asset_to_routes.contains_key(&canonical); 181 + } 182 + 183 + false 184 + }); 185 + 186 + if should { 187 + info!(target: "build", "Rebundling needed: changed file affects bundled assets"); 188 + } else { 189 + info!(target: "build", "Skipping bundler: no changed files affect bundled assets"); 190 + } 191 + 192 + should 193 + } else { 194 + // Not incremental or no previous bundler inputs 195 + false 196 + }; 197 + 198 + let clean_up_handle = if options.clean_output_dir && !is_incremental { 199 let old_dist_tmp_dir = { 200 let duration = SystemTime::now().duration_since(UNIX_EPOCH)?; 201 let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000; ··· 212 }; 213 214 // Create the image cache early so it can be shared across routes 215 + let image_cache = ImageCache::with_cache_dir(options.assets_cache_dir()); 216 let _ = fs::create_dir_all(image_cache.get_cache_dir()); 217 218 // Create route_assets_options with the image cache ··· 321 322 // Static base route 323 if base_params.is_empty() { 324 + let route_id = RouteIdentifier::base(base_path.clone(), None); 325 + 326 + // Check if we need to rebuild this route 327 + if should_rebuild_route(&route_id, &routes_to_rebuild) { 328 + let mut route_assets = RouteAssets::with_default_assets( 329 + &route_assets_options, 330 + Some(image_cache.clone()), 331 + default_scripts.clone(), 332 + vec![], 333 + ); 334 335 + let params = PageParams::default(); 336 + let url = cached_route.url(&params); 337 338 + let result = route.build(&mut PageContext::from_static_route( 339 + content_sources, 340 + &mut route_assets, 341 + &url, 342 + &options.base_url, 343 + None, 344 + ))?; 345 346 + let file_path = cached_route.file_path(&params, &options.output_dir); 347 348 + write_route_file(&result, &file_path)?; 349 350 + info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 351 352 + // Track assets for this route 353 + track_route_assets(&mut build_state, &route_id, &route_assets); 354 355 + build_pages_images.extend(route_assets.images); 356 + build_pages_scripts.extend(route_assets.scripts); 357 + build_pages_styles.extend(route_assets.styles); 358 + 359 + build_metadata.add_page( 360 + base_path.clone(), 361 + file_path.to_string_lossy().to_string(), 362 + None, 363 + ); 364 365 + add_sitemap_entry( 366 + &mut sitemap_entries, 367 + normalized_base_url, 368 + &url, 369 + base_path, 370 + &route.sitemap_metadata(), 371 + &options.sitemap, 372 + ); 373 374 + page_count += 1; 375 + } else { 376 + trace!(target: "build", "Skipping unchanged route: {}", base_path); 377 + } 378 } else { 379 // Dynamic base route 380 let mut route_assets = RouteAssets::with_default_assets( ··· 398 399 // Build all pages for this route 400 for page in pages { 401 + let route_id = 402 + RouteIdentifier::base(base_path.clone(), Some(page.0.0.clone())); 403 404 + // Check if we need to rebuild this specific page 405 + if should_rebuild_route(&route_id, &routes_to_rebuild) { 406 + let page_start = Instant::now(); 407 + let url = cached_route.url(&page.0); 408 + let file_path = cached_route.file_path(&page.0, &options.output_dir); 409 410 + let content = route.build(&mut PageContext::from_dynamic_route( 411 + &page, 412 + content_sources, 413 + &mut route_assets, 414 + &url, 415 + &options.base_url, 416 + None, 417 + ))?; 418 419 + write_route_file(&content, &file_path)?; 420 421 + info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options)); 422 + 423 + // Track assets for this page 424 + track_route_assets(&mut build_state, &route_id, &route_assets); 425 + 426 + build_metadata.add_page( 427 + base_path.clone(), 428 + file_path.to_string_lossy().to_string(), 429 + Some(page.0.0.clone()), 430 + ); 431 432 + add_sitemap_entry( 433 + &mut sitemap_entries, 434 + normalized_base_url, 435 + &url, 436 + base_path, 437 + &route.sitemap_metadata(), 438 + &options.sitemap, 439 + ); 440 441 + page_count += 1; 442 + } else { 443 + trace!(target: "build", "Skipping unchanged page: {} with params {:?}", base_path, page.0.0); 444 + } 445 } 446 } 447 ··· 458 459 if variant_params.is_empty() { 460 // Static variant 461 + let route_id = 462 + RouteIdentifier::variant(variant_id.clone(), variant_path.clone(), None); 463 464 + // Check if we need to rebuild this variant 465 + if should_rebuild_route(&route_id, &routes_to_rebuild) { 466 + let mut route_assets = RouteAssets::with_default_assets( 467 + &route_assets_options, 468 + Some(image_cache.clone()), 469 + default_scripts.clone(), 470 + vec![], 471 + ); 472 473 + let params = PageParams::default(); 474 + let url = cached_route.variant_url(&params, &variant_id)?; 475 + let file_path = cached_route.variant_file_path( 476 + &params, 477 + &options.output_dir, 478 + &variant_id, 479 + )?; 480 + 481 + let result = route.build(&mut PageContext::from_static_route( 482 + content_sources, 483 + &mut route_assets, 484 + &url, 485 + &options.base_url, 486 + Some(variant_id.clone()), 487 + ))?; 488 + 489 + write_route_file(&result, &file_path)?; 490 491 + info!(target: "pages", "โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options)); 492 493 + // Track assets for this variant 494 + track_route_assets(&mut build_state, &route_id, &route_assets); 495 496 + build_pages_images.extend(route_assets.images); 497 + build_pages_scripts.extend(route_assets.scripts); 498 + build_pages_styles.extend(route_assets.styles); 499 500 + build_metadata.add_page( 501 + variant_path.clone(), 502 + file_path.to_string_lossy().to_string(), 503 + None, 504 + ); 505 506 + add_sitemap_entry( 507 + &mut sitemap_entries, 508 + normalized_base_url, 509 + &url, 510 + &variant_path, 511 + &route.sitemap_metadata(), 512 + &options.sitemap, 513 + ); 514 515 + page_count += 1; 516 + } else { 517 + trace!(target: "build", "Skipping unchanged variant: {}", variant_path); 518 + } 519 } else { 520 // Dynamic variant 521 let mut route_assets = RouteAssets::with_default_assets( ··· 538 539 // Build all pages for this variant group 540 for page in pages { 541 + let route_id = RouteIdentifier::variant( 542 + variant_id.clone(), 543 + variant_path.clone(), 544 + Some(page.0.0.clone()), 545 + ); 546 547 + // Check if we need to rebuild this specific variant page 548 + if should_rebuild_route(&route_id, &routes_to_rebuild) { 549 + let variant_page_start = Instant::now(); 550 + let url = cached_route.variant_url(&page.0, &variant_id)?; 551 + let file_path = cached_route.variant_file_path( 552 + &page.0, 553 + &options.output_dir, 554 + &variant_id, 555 + )?; 556 557 + let content = route.build(&mut PageContext::from_dynamic_route( 558 + &page, 559 + content_sources, 560 + &mut route_assets, 561 + &url, 562 + &options.base_url, 563 + Some(variant_id.clone()), 564 + ))?; 565 + 566 + write_route_file(&content, &file_path)?; 567 + 568 + info!(target: "pages", "โ”‚ โ”œโ”€ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options)); 569 570 + // Track assets for this variant page 571 + track_route_assets(&mut build_state, &route_id, &route_assets); 572 573 + build_metadata.add_page( 574 + variant_path.clone(), 575 + file_path.to_string_lossy().to_string(), 576 + Some(page.0.0.clone()), 577 + ); 578 579 + add_sitemap_entry( 580 + &mut sitemap_entries, 581 + normalized_base_url, 582 + &url, 583 + &variant_path, 584 + &route.sitemap_metadata(), 585 + &options.sitemap, 586 + ); 587 588 + page_count += 1; 589 + } else { 590 + trace!(target: "build", "Skipping unchanged variant page: {} with params {:?}", variant_path, page.0.0); 591 + } 592 } 593 } 594 ··· 608 fs::create_dir_all(&route_assets_options.output_assets_dir)?; 609 } 610 611 + if !build_pages_styles.is_empty() 612 + || !build_pages_scripts.is_empty() 613 + || (is_incremental && should_rebundle) 614 + { 615 let assets_start = Instant::now(); 616 print_title("generating assets"); 617 ··· 629 }) 630 .collect::<Vec<InputItem>>(); 631 632 + let mut bundler_inputs = build_pages_scripts 633 .iter() 634 .map(|script| InputItem { 635 import: script.path().to_string_lossy().to_string(), ··· 644 .chain(css_inputs.into_iter()) 645 .collect::<Vec<InputItem>>(); 646 647 + // During incremental builds, merge with previous bundler inputs 648 + // to ensure we bundle all assets, not just from rebuilt routes 649 + if is_incremental && !build_state.bundler_inputs.is_empty() { 650 + debug!(target: "bundling", "Merging with {} previous bundler inputs", build_state.bundler_inputs.len()); 651 + 652 + let current_imports: FxHashSet<String> = bundler_inputs 653 + .iter() 654 + .map(|input| input.import.clone()) 655 + .collect(); 656 + 657 + // Add previous inputs that aren't in the current set 658 + for prev_input in &build_state.bundler_inputs { 659 + if !current_imports.contains(prev_input) { 660 + bundler_inputs.push(InputItem { 661 + import: prev_input.clone(), 662 + name: Some( 663 + PathBuf::from(prev_input) 664 + .file_stem() 665 + .unwrap_or_default() 666 + .to_string_lossy() 667 + .to_string(), 668 + ), 669 + }); 670 + } 671 + } 672 + } 673 + 674 debug!( 675 target: "bundling", 676 "Bundler inputs: {:?}", ··· 680 .collect::<Vec<String>>() 681 ); 682 683 + // Store bundler inputs in build state for next incremental build 684 + if options.incremental { 685 + build_state.bundler_inputs = bundler_inputs 686 + .iter() 687 + .map(|input| input.import.clone()) 688 + .collect(); 689 + } 690 + 691 if !bundler_inputs.is_empty() { 692 let mut module_types_hashmap = FxHashMap::default(); 693 + // Fonts 694 module_types_hashmap.insert("woff".to_string(), ModuleType::Asset); 695 module_types_hashmap.insert("woff2".to_string(), ModuleType::Asset); 696 + module_types_hashmap.insert("ttf".to_string(), ModuleType::Asset); 697 + module_types_hashmap.insert("otf".to_string(), ModuleType::Asset); 698 + module_types_hashmap.insert("eot".to_string(), ModuleType::Asset); 699 + // Images 700 + module_types_hashmap.insert("png".to_string(), ModuleType::Asset); 701 + module_types_hashmap.insert("jpg".to_string(), ModuleType::Asset); 702 + module_types_hashmap.insert("jpeg".to_string(), ModuleType::Asset); 703 + module_types_hashmap.insert("gif".to_string(), ModuleType::Asset); 704 + module_types_hashmap.insert("svg".to_string(), ModuleType::Asset); 705 + module_types_hashmap.insert("webp".to_string(), ModuleType::Asset); 706 + module_types_hashmap.insert("avif".to_string(), ModuleType::Asset); 707 + module_types_hashmap.insert("ico".to_string(), ModuleType::Asset); 708 709 let mut bundler = Bundler::with_plugins( 710 BundlerOptions { ··· 738 ], 739 )?; 740 741 + let result = bundler.write().await?; 742 743 + // Track transitive dependencies from bundler output 744 + // For each chunk, map all its modules to the routes that use the entry point 745 + // For assets (images, fonts via CSS url()), map them to all routes using any entry point 746 + if options.incremental { 747 + // First, collect all routes that use any bundler entry point 748 + let mut all_bundler_routes: FxHashSet<RouteIdentifier> = FxHashSet::default(); 749 + 750 + for output in &result.assets { 751 + if let Output::Chunk(chunk) = output { 752 + // Get the entry point for this chunk 753 + if let Some(facade_module_id) = &chunk.facade_module_id { 754 + // Try to find routes using this entry point 755 + let entry_path = PathBuf::from(facade_module_id.as_str()); 756 + let canonical_entry = entry_path.canonicalize().ok(); 757 + 758 + // Look up routes for this entry point 759 + let routes = canonical_entry 760 + .as_ref() 761 + .and_then(|p| build_state.asset_to_routes.get(p)) 762 + .cloned(); 763 + 764 + if let Some(routes) = routes { 765 + // Collect routes for asset tracking later 766 + all_bundler_routes.extend(routes.iter().cloned()); 767 + 768 + // Register all modules in this chunk as dependencies for those routes 769 + let mut transitive_count = 0; 770 + for module_id in &chunk.module_ids { 771 + let module_path = PathBuf::from(module_id.as_str()); 772 + if let Ok(canonical_module) = module_path.canonicalize() { 773 + // Skip the entry point itself (already tracked) 774 + if Some(&canonical_module) != canonical_entry.as_ref() { 775 + for route in &routes { 776 + build_state.track_asset( 777 + canonical_module.clone(), 778 + route.clone(), 779 + ); 780 + } 781 + transitive_count += 1; 782 + } 783 + } 784 + } 785 + if transitive_count > 0 { 786 + debug!(target: "build", "Tracked {} transitive dependencies for {}", transitive_count, facade_module_id); 787 + } 788 + } 789 + } 790 + } 791 + } 792 + 793 + // Now track Output::Asset items (images, fonts, etc. referenced via CSS url() or JS imports) 794 + // These are mapped to all routes that use any bundler entry point 795 + if !all_bundler_routes.is_empty() { 796 + let mut asset_count = 0; 797 + for output in &result.assets { 798 + if let Output::Asset(asset) = output { 799 + for original_file in &asset.original_file_names { 800 + let asset_path = PathBuf::from(original_file); 801 + if let Ok(canonical_asset) = asset_path.canonicalize() { 802 + for route in &all_bundler_routes { 803 + build_state 804 + .track_asset(canonical_asset.clone(), route.clone()); 805 + } 806 + asset_count += 1; 807 + } 808 + } 809 + } 810 + } 811 + if asset_count > 0 { 812 + debug!(target: "build", "Tracked {} bundler assets for {} routes", asset_count, all_bundler_routes.len()); 813 + } 814 + } 815 + } 816 } 817 818 info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), &section_format_options)).bold()); ··· 908 info!(target: "SKIP_FORMAT", "{}", ""); 909 info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), &section_format_options)).bold()); 910 911 + // Save build state for next incremental build (only if incremental is enabled) 912 + if options.incremental { 913 + if let Err(e) = build_state.save(build_cache_dir) { 914 + warn!(target: "build", "Failed to save build state: {}", e); 915 + } else { 916 + debug!(target: "build", "Build state saved to {}", build_cache_dir.join("build_state.json").display()); 917 + } 918 + } 919 + 920 if let Some(clean_up_handle) = clean_up_handle { 921 clean_up_handle.await?; 922 } ··· 999 fs::create_dir_all(parent_dir)? 1000 } 1001 1002 + trace!(target: "build", "Writing HTML file: {}", file_path.display()); 1003 fs::write(file_path, content)?; 1004 1005 Ok(())
+22 -3
crates/maudit/src/lib.rs
··· 54 // Internal modules 55 mod logging; 56 57 - use std::env; 58 59 use build::execute_build; 60 use content::ContentSources; 61 use logging::init_logging; 62 use route::FullRoute; 63 64 /// 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. 68 pub fn is_dev() -> bool { 69 - env::var("MAUDIT_DEV").map(|v| v == "true").unwrap_or(false) 70 } 71 72 #[macro_export] ··· 212 .enable_all() 213 .build()?; 214 215 - execute_build(routes, &mut content_sources, &options, &async_runtime) 216 }
··· 54 // Internal modules 55 mod logging; 56 57 + use std::sync::LazyLock; 58 + use std::{env, path::PathBuf}; 59 60 use build::execute_build; 61 use content::ContentSources; 62 use logging::init_logging; 63 use route::FullRoute; 64 65 + 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. 75 pub fn is_dev() -> bool { 76 + *IS_DEV 77 } 78 79 #[macro_export] ··· 219 .enable_all() 220 .build()?; 221 222 + // 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 }
+6 -2
crates/maudit/src/logging.rs
··· 29 30 let _ = Builder::from_env(logging_env) 31 .format(|buf, record| { 32 - if std::env::args().any(|arg| arg == "--quiet") || std::env::var("MAUDIT_QUIET").is_ok() 33 - { 34 return Ok(()); 35 } 36
··· 29 30 let _ = Builder::from_env(logging_env) 31 .format(|buf, record| { 32 + if std::env::args().any(|arg| arg == "--quiet") { 33 + return Ok(()); 34 + } 35 + 36 + // In quiet mode, only show build target logs (for debugging incremental builds) 37 + if std::env::var("MAUDIT_QUIET").is_ok() && record.target() != "build" { 38 return Ok(()); 39 } 40
+5
crates/maudit-cli/Cargo.toml
··· 28 ureq = "3.1.4" 29 tar = "0.4.44" 30 toml_edit = "0.24.0" 31 local-ip-address = "0.6.9" 32 flate2 = "1.1.8" 33 quanta = "0.12.6" 34 serde_json = "1.0" 35 tokio-util = "0.7" 36 cargo_metadata = "0.23.1"
··· 28 ureq = "3.1.4" 29 tar = "0.4.44" 30 toml_edit = "0.24.0" 31 + toml = "0.8" 32 local-ip-address = "0.6.9" 33 flate2 = "1.1.8" 34 quanta = "0.12.6" 35 serde_json = "1.0" 36 tokio-util = "0.7" 37 cargo_metadata = "0.23.1" 38 + 39 + [dev-dependencies] 40 + tempfile = "3.24.0" 41 + tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] }
+499 -142
crates/maudit-cli/src/dev/build.rs
··· 1 use cargo_metadata::Message; 2 use quanta::Instant; 3 - use server::{StatusType, WebSocketMessage, update_status}; 4 use std::sync::Arc; 5 use tokio::process::Command; 6 - use tokio::sync::broadcast; 7 use tokio_util::sync::CancellationToken; 8 - use tracing::{debug, error, info}; 9 10 use crate::{ 11 - dev::server, 12 logging::{FormatElapsedTimeOptions, format_elapsed_time}, 13 }; 14 15 #[derive(Clone)] 16 pub struct BuildManager { 17 - current_cancel: Arc<tokio::sync::RwLock<Option<CancellationToken>>>, 18 - build_semaphore: Arc<tokio::sync::Semaphore>, 19 - websocket_tx: broadcast::Sender<WebSocketMessage>, 20 - current_status: Arc<tokio::sync::RwLock<Option<server::PersistentStatus>>>, 21 } 22 23 impl BuildManager { 24 - pub fn new(websocket_tx: broadcast::Sender<WebSocketMessage>) -> Self { 25 Self { 26 - current_cancel: Arc::new(tokio::sync::RwLock::new(None)), 27 - build_semaphore: Arc::new(tokio::sync::Semaphore::new(1)), // Only one build at a time 28 - websocket_tx, 29 - current_status: Arc::new(tokio::sync::RwLock::new(None)), 30 } 31 } 32 33 - /// Get a reference to the current status for use with the web server 34 - pub fn current_status(&self) -> Arc<tokio::sync::RwLock<Option<server::PersistentStatus>>> { 35 - self.current_status.clone() 36 } 37 38 - /// Do initial build that can be cancelled (but isn't stored as current build) 39 - pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error>> { 40 self.internal_build(true).await 41 } 42 43 - /// Start a new build, cancelling any previous one 44 - pub async fn start_build(&self) -> Result<bool, Box<dyn std::error::Error>> { 45 self.internal_build(false).await 46 } 47 48 - /// Internal build method that handles both initial and regular builds 49 - async fn internal_build(&self, is_initial: bool) -> Result<bool, Box<dyn std::error::Error>> { 50 // Cancel any existing build immediately 51 let cancel = CancellationToken::new(); 52 { 53 - let mut current_cancel = self.current_cancel.write().await; 54 if let Some(old_cancel) = current_cancel.replace(cancel.clone()) { 55 old_cancel.cancel(); 56 } 57 } 58 59 // Acquire semaphore to ensure only one build runs at a time 60 - // This prevents resource conflicts if cancellation fails 61 - let _ = self.build_semaphore.acquire().await?; 62 63 - // Notify that build is starting 64 - update_status( 65 - &self.websocket_tx, 66 - self.current_status.clone(), 67 - StatusType::Info, 68 - "Building...", 69 - ) 70 - .await; 71 72 let mut child = Command::new("cargo") 73 .args([ ··· 85 .stderr(std::process::Stdio::piped()) 86 .spawn()?; 87 88 - // Take the stderr stream for manual handling 89 - let mut stdout = child.stdout.take().unwrap(); 90 - let mut stderr = child.stderr.take().unwrap(); 91 92 - let websocket_tx = self.websocket_tx.clone(); 93 - let current_status = self.current_status.clone(); 94 let build_start_time = Instant::now(); 95 96 - // Create a channel to get the build result back 97 - let (result_tx, mut result_rx) = tokio::sync::mpsc::channel::<bool>(1); 98 99 - // Spawn watcher task to monitor the child process 100 - tokio::spawn(async move { 101 - let output_future = async { 102 - // Read stdout concurrently with waiting for process to finish 103 - let stdout_task = tokio::spawn(async move { 104 - let mut out = Vec::new(); 105 - tokio::io::copy(&mut stdout, &mut out).await.unwrap_or(0); 106 107 - let mut rendered_messages: Vec<String> = Vec::new(); 108 109 - // Ideally we'd stream things as they come, but I can't figure it out 110 - for message in cargo_metadata::Message::parse_stream( 111 - String::from_utf8_lossy(&out).to_string().as_bytes(), 112 - ) { 113 - match message { 114 - Err(e) => { 115 - error!(name: "build", "Failed to parse cargo message: {}", e); 116 - continue; 117 - } 118 - Ok(message) => { 119 - match message { 120 - // Compiler wants to tell us something 121 - Message::CompilerMessage(msg) => { 122 - // TODO: For now, just send through the rendered messages, but in the future let's send 123 - // structured messages to the frontend so we can do better formatting 124 - if let Some(rendered) = &msg.message.rendered { 125 - info!("{}", rendered); 126 - rendered_messages.push(rendered.to_string()); 127 - } 128 - } 129 - // Random text came in, just log it 130 - Message::TextLine(msg) => { 131 - info!("{}", msg); 132 - } 133 - _ => {} 134 - } 135 - } 136 } 137 } 138 139 - (out, rendered_messages) 140 - }); 141 142 - let stderr_task = tokio::spawn(async move { 143 - let mut err = Vec::new(); 144 - tokio::io::copy(&mut stderr, &mut err).await.unwrap_or(0); 145 146 - err 147 - }); 148 149 - let status = child.wait().await?; 150 - let stdout_data = stdout_task.await.unwrap_or_default(); 151 - let stderr_data = stderr_task.await.unwrap_or_default(); 152 153 - Ok::<(std::process::Output, Vec<String>), Box<dyn std::error::Error + Send + Sync>>( 154 - ( 155 - std::process::Output { 156 - status, 157 - stdout: stdout_data.0, 158 - stderr: stderr_data, 159 - }, 160 - stdout_data.1, 161 - ), 162 - ) 163 }; 164 165 - tokio::select! { 166 - _ = cancel.cancelled() => { 167 - debug!(name: "build", "Build cancelled"); 168 - let _ = child.kill().await; 169 - update_status(&websocket_tx, current_status, StatusType::Info, "Build cancelled").await; 170 - let _ = result_tx.send(false).await; // Build failed due to cancellation 171 - } 172 - res = output_future => { 173 - let duration = build_start_time.elapsed(); 174 - let formatted_elapsed_time = format_elapsed_time( 175 - duration, 176 - &FormatElapsedTimeOptions::default_dev(), 177 - ); 178 179 - let success = match res { 180 - Ok(output) => { 181 - let (output, rendered_messages) = output; 182 - if output.status.success() { 183 - let build_type = if is_initial { "Initial build" } else { "Rebuild" }; 184 - info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time); 185 - update_status(&websocket_tx, current_status, StatusType::Success, "Build finished successfully").await; 186 - true 187 - } else { 188 - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); 189 - println!("{}", stderr); // Raw stderr sometimes has something to say whenever cargo fails, even if the errors messages are actually in stdout 190 - let build_type = if is_initial { "Initial build" } else { "Rebuild" }; 191 - error!(name: "build", "{} failed with errors {}", build_type, formatted_elapsed_time); 192 - if is_initial { 193 - error!(name: "build", "Initial build needs to succeed before we can start the dev server"); 194 - update_status(&websocket_tx, current_status, StatusType::Error, "Initial build failed - fix errors and save to retry").await; 195 - } else { 196 - update_status(&websocket_tx, current_status, StatusType::Error, &rendered_messages.join("\n")).await; 197 - } 198 - false 199 - } 200 - } 201 - Err(e) => { 202 - error!(name: "build", "Failed to wait for build: {}", e); 203 - update_status(&websocket_tx, current_status, StatusType::Error, &format!("Failed to wait for build: {}", e)).await; 204 - false 205 - } 206 - }; 207 - let _ = result_tx.send(success).await; 208 - } 209 } 210 - }); 211 212 - // Wait for the build result 213 - let success = result_rx.recv().await.unwrap_or(false); 214 - Ok(success) 215 } 216 }
··· 1 use cargo_metadata::Message; 2 use quanta::Instant; 3 + use std::path::PathBuf; 4 use std::sync::Arc; 5 use tokio::process::Command; 6 + use tokio::sync::RwLock; 7 use tokio_util::sync::CancellationToken; 8 + use tracing::{debug, error, info, warn}; 9 10 use crate::{ 11 + dev::server::{StatusManager, StatusType}, 12 logging::{FormatElapsedTimeOptions, format_elapsed_time}, 13 }; 14 15 + use super::dep_tracker::{DependencyTracker, find_target_dir}; 16 + 17 + /// Internal state shared across all BuildManager handles. 18 + struct BuildManagerState { 19 + current_cancel: RwLock<Option<CancellationToken>>, 20 + build_semaphore: tokio::sync::Semaphore, 21 + status_manager: StatusManager, 22 + dep_tracker: RwLock<Option<DependencyTracker>>, 23 + binary_path: RwLock<Option<PathBuf>>, 24 + // Cached values computed once at startup 25 + target_dir: Option<PathBuf>, 26 + binary_name: Option<String>, 27 + } 28 + 29 + /// Manages cargo build processes with cancellation support. 30 + /// Cheap to clone - all clones share the same underlying state. 31 #[derive(Clone)] 32 pub struct BuildManager { 33 + state: Arc<BuildManagerState>, 34 } 35 36 impl BuildManager { 37 + pub fn new(status_manager: StatusManager) -> Self { 38 + // Try to determine target directory and binary name at startup 39 + let target_dir = find_target_dir().ok(); 40 + let binary_name = Self::get_binary_name_from_cargo_toml().ok(); 41 + 42 + if let Some(ref name) = binary_name { 43 + debug!(name: "build", "Detected binary name at startup: {}", name); 44 + } 45 + if let Some(ref dir) = target_dir { 46 + debug!(name: "build", "Using target directory: {:?}", dir); 47 + } 48 + 49 Self { 50 + state: Arc::new(BuildManagerState { 51 + current_cancel: RwLock::new(None), 52 + build_semaphore: tokio::sync::Semaphore::new(1), 53 + status_manager, 54 + dep_tracker: RwLock::new(None), 55 + binary_path: RwLock::new(None), 56 + target_dir, 57 + binary_name, 58 + }), 59 } 60 } 61 62 + /// Check if the given paths require recompilation based on dependency tracking. 63 + /// Returns true if recompilation is needed, false if we can just rerun the binary. 64 + pub async fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool { 65 + let dep_tracker = self.state.dep_tracker.read().await; 66 + 67 + if let Some(tracker) = dep_tracker.as_ref() 68 + && tracker.has_dependencies() 69 + { 70 + let needs_recompile = tracker.needs_recompile(changed_paths); 71 + if !needs_recompile { 72 + debug!(name: "build", "Changed files are not dependencies, rerun binary without recompile"); 73 + } 74 + return needs_recompile; 75 + } 76 + 77 + // If we don't have a dependency tracker yet, always recompile 78 + true 79 + } 80 + 81 + /// Rerun the binary without recompiling. 82 + pub async fn rerun_binary( 83 + &self, 84 + changed_paths: &[PathBuf], 85 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 86 + // Get binary path with limited lock scope 87 + let path = { 88 + let guard = self.state.binary_path.read().await; 89 + match guard.as_ref() { 90 + Some(p) if p.exists() => p.clone(), 91 + Some(p) => { 92 + warn!(name: "build", "Binary at {:?} no longer exists, falling back to full rebuild", p); 93 + return self.start_build().await; 94 + } 95 + None => { 96 + warn!(name: "build", "No binary path available, falling back to full rebuild"); 97 + return self.start_build().await; 98 + } 99 + } 100 + }; 101 + 102 + // Log that we're doing an incremental build 103 + debug!(name: "build", "Incremental build: {} files changed", changed_paths.len()); 104 + debug!(name: "build", "Changed files: {:?}", changed_paths); 105 + debug!(name: "build", "Rerunning binary without recompilation..."); 106 + 107 + self.state 108 + .status_manager 109 + .update(StatusType::Info, "Rerunning...") 110 + .await; 111 + 112 + let build_start_time = Instant::now(); 113 + 114 + // Serialize changed paths to JSON for the binary 115 + let changed_files_json = serde_json::to_string(changed_paths)?; 116 + 117 + let child = Command::new(&path) 118 + .envs([ 119 + ("MAUDIT_DEV", "true"), 120 + ("MAUDIT_QUIET", "true"), 121 + ("MAUDIT_CHANGED_FILES", changed_files_json.as_str()), 122 + ]) 123 + .stdout(std::process::Stdio::piped()) 124 + .stderr(std::process::Stdio::piped()) 125 + .spawn()?; 126 + 127 + let output = child.wait_with_output().await?; 128 + 129 + let duration = build_start_time.elapsed(); 130 + let formatted_elapsed_time = 131 + format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev()); 132 + 133 + if output.status.success() { 134 + if std::env::var("MAUDIT_SHOW_BINARY_OUTPUT").is_ok() { 135 + let stdout = String::from_utf8_lossy(&output.stdout); 136 + let stderr = String::from_utf8_lossy(&output.stderr); 137 + for line in stdout.lines().chain(stderr.lines()) { 138 + if !line.trim().is_empty() { 139 + info!(name: "build", "{}", line); 140 + } 141 + } 142 + } 143 + info!(name: "build", "Binary rerun finished {}", formatted_elapsed_time); 144 + self.state 145 + .status_manager 146 + .update(StatusType::Success, "Binary rerun finished successfully") 147 + .await; 148 + Ok(true) 149 + } else { 150 + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); 151 + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); 152 + error!(name: "build", "Binary rerun failed {}\nstdout: {}\nstderr: {}", 153 + formatted_elapsed_time, stdout, stderr); 154 + self.state 155 + .status_manager 156 + .update( 157 + StatusType::Error, 158 + &format!("Binary rerun failed:\n{}\n{}", stdout, stderr), 159 + ) 160 + .await; 161 + Ok(false) 162 + } 163 } 164 165 + /// Do initial build that can be cancelled. 166 + pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 167 self.internal_build(true).await 168 } 169 170 + /// Start a new build, cancelling any previous one. 171 + pub async fn start_build(&self) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 172 self.internal_build(false).await 173 } 174 175 + async fn internal_build( 176 + &self, 177 + is_initial: bool, 178 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 179 // Cancel any existing build immediately 180 let cancel = CancellationToken::new(); 181 { 182 + let mut current_cancel = self.state.current_cancel.write().await; 183 if let Some(old_cancel) = current_cancel.replace(cancel.clone()) { 184 old_cancel.cancel(); 185 } 186 } 187 188 // Acquire semaphore to ensure only one build runs at a time 189 + let _permit = self.state.build_semaphore.acquire().await?; 190 191 + self.state 192 + .status_manager 193 + .update(StatusType::Info, "Building...") 194 + .await; 195 196 let mut child = Command::new("cargo") 197 .args([ ··· 209 .stderr(std::process::Stdio::piped()) 210 .spawn()?; 211 212 + // Take stdout/stderr before select! so we can use them in the completion branch 213 + // while still being able to kill the child in the cancellation branch 214 + let stdout = child.stdout.take().unwrap(); 215 + let stderr = child.stderr.take().unwrap(); 216 217 let build_start_time = Instant::now(); 218 219 + tokio::select! { 220 + _ = cancel.cancelled() => { 221 + debug!(name: "build", "Build cancelled"); 222 + let _ = child.kill().await; 223 + self.state.status_manager.update(StatusType::Info, "Build cancelled").await; 224 + Ok(false) 225 + } 226 + result = self.run_build_to_completion(&mut child, stdout, stderr, is_initial, build_start_time) => { 227 + result 228 + } 229 + } 230 + } 231 232 + /// Run the cargo build process to completion and handle the output. 233 + async fn run_build_to_completion( 234 + &self, 235 + child: &mut tokio::process::Child, 236 + mut stdout: tokio::process::ChildStdout, 237 + mut stderr: tokio::process::ChildStderr, 238 + is_initial: bool, 239 + build_start_time: Instant, 240 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 241 + // Read stdout and stderr concurrently 242 + let stdout_task = tokio::spawn(async move { 243 + let mut out = Vec::new(); 244 + tokio::io::copy(&mut stdout, &mut out).await.unwrap_or(0); 245 246 + let mut rendered_messages: Vec<String> = Vec::new(); 247 248 + for message in cargo_metadata::Message::parse_stream( 249 + String::from_utf8_lossy(&out).to_string().as_bytes(), 250 + ) { 251 + match message { 252 + Err(e) => { 253 + error!(name: "build", "Failed to parse cargo message: {}", e); 254 + } 255 + Ok(Message::CompilerMessage(msg)) => { 256 + if let Some(rendered) = &msg.message.rendered { 257 + info!("{}", rendered); 258 + rendered_messages.push(rendered.to_string()); 259 } 260 } 261 + Ok(Message::TextLine(msg)) => { 262 + info!("{}", msg); 263 + } 264 + _ => {} 265 + } 266 + } 267 268 + (out, rendered_messages) 269 + }); 270 271 + let stderr_task = tokio::spawn(async move { 272 + let mut err = Vec::new(); 273 + tokio::io::copy(&mut stderr, &mut err).await.unwrap_or(0); 274 + err 275 + }); 276 277 + let status = child.wait().await?; 278 + let (_stdout_bytes, rendered_messages) = stdout_task.await.unwrap_or_default(); 279 + let stderr_bytes = stderr_task.await.unwrap_or_default(); 280 281 + let duration = build_start_time.elapsed(); 282 + let formatted_elapsed_time = 283 + format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev()); 284 + 285 + if status.success() { 286 + let build_type = if is_initial { 287 + "Initial build" 288 + } else { 289 + "Rebuild" 290 + }; 291 + info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time); 292 + self.state 293 + .status_manager 294 + .update(StatusType::Success, "Build finished successfully") 295 + .await; 296 + 297 + self.update_dependency_tracker().await; 298 + 299 + Ok(true) 300 + } else { 301 + let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string(); 302 + // Raw stderr sometimes has something to say whenever cargo fails 303 + println!("{}", stderr_str); 304 305 + let build_type = if is_initial { 306 + "Initial build" 307 + } else { 308 + "Rebuild" 309 }; 310 + error!(name: "build", "{} failed with errors {}", build_type, formatted_elapsed_time); 311 312 + if is_initial { 313 + error!(name: "build", "Initial build needs to succeed before we can start the dev server"); 314 + self.state 315 + .status_manager 316 + .update( 317 + StatusType::Error, 318 + "Initial build failed - fix errors and save to retry", 319 + ) 320 + .await; 321 + } else { 322 + self.state 323 + .status_manager 324 + .update(StatusType::Error, &rendered_messages.join("\n")) 325 + .await; 326 + } 327 328 + Ok(false) 329 + } 330 + } 331 + 332 + /// Update the dependency tracker after a successful build. 333 + async fn update_dependency_tracker(&self) { 334 + let Some(ref name) = self.state.binary_name else { 335 + debug!(name: "build", "No binary name available, skipping dependency tracker update"); 336 + return; 337 + }; 338 + 339 + let Some(ref target) = self.state.target_dir else { 340 + debug!(name: "build", "No target directory available, skipping dependency tracker update"); 341 + return; 342 + }; 343 + 344 + // Update binary path 345 + let bin_path = target.join(name); 346 + if bin_path.exists() { 347 + *self.state.binary_path.write().await = Some(bin_path.clone()); 348 + debug!(name: "build", "Binary path set to: {:?}", bin_path); 349 + } else { 350 + debug!(name: "build", "Binary not found at expected path: {:?}", bin_path); 351 + } 352 + 353 + // Reload the dependency tracker from the .d file 354 + match DependencyTracker::load_from_binary_name(name) { 355 + Ok(tracker) => { 356 + debug!(name: "build", "Loaded {} dependencies for tracking", tracker.get_dependencies().len()); 357 + *self.state.dep_tracker.write().await = Some(tracker); 358 } 359 + Err(e) => { 360 + debug!(name: "build", "Could not load dependency tracker: {}", e); 361 + } 362 + } 363 + } 364 365 + fn get_binary_name_from_cargo_toml() -> Result<String, Box<dyn std::error::Error + Send + Sync>> 366 + { 367 + let cargo_toml_path = PathBuf::from("Cargo.toml"); 368 + if !cargo_toml_path.exists() { 369 + return Err("Cargo.toml not found in current directory".into()); 370 + } 371 + 372 + let cargo_toml_content = std::fs::read_to_string(&cargo_toml_path)?; 373 + let cargo_toml: toml::Value = toml::from_str(&cargo_toml_content)?; 374 + 375 + if let Some(package_name) = cargo_toml 376 + .get("package") 377 + .and_then(|p| p.get("name")) 378 + .and_then(|n| n.as_str()) 379 + { 380 + // Check if there's a [[bin]] section with a different name 381 + if let Some(bins) = cargo_toml.get("bin").and_then(|b| b.as_array()) 382 + && let Some(first_bin) = bins.first() 383 + && let Some(bin_name) = first_bin.get("name").and_then(|n| n.as_str()) 384 + { 385 + return Ok(bin_name.to_string()); 386 + } 387 + 388 + return Ok(package_name.to_string()); 389 + } 390 + 391 + Err("Could not find package name in Cargo.toml".into()) 392 + } 393 + 394 + /// Set the dependency tracker directly (for testing). 395 + #[cfg(test)] 396 + pub(crate) async fn set_dep_tracker(&self, tracker: Option<DependencyTracker>) { 397 + *self.state.dep_tracker.write().await = tracker; 398 + } 399 + 400 + /// Set the binary path directly (for testing). 401 + #[cfg(test)] 402 + pub(crate) async fn set_binary_path(&self, path: Option<PathBuf>) { 403 + *self.state.binary_path.write().await = path; 404 + } 405 + 406 + /// Get the current binary path (for testing). 407 + #[cfg(test)] 408 + pub(crate) async fn get_binary_path(&self) -> Option<PathBuf> { 409 + self.state.binary_path.read().await.clone() 410 + } 411 + 412 + /// Create a BuildManager with custom target_dir and binary_name (for testing). 413 + #[cfg(test)] 414 + pub(crate) fn new_with_config( 415 + status_manager: StatusManager, 416 + target_dir: Option<PathBuf>, 417 + binary_name: Option<String>, 418 + ) -> Self { 419 + Self { 420 + state: Arc::new(BuildManagerState { 421 + current_cancel: RwLock::new(None), 422 + build_semaphore: tokio::sync::Semaphore::new(1), 423 + status_manager, 424 + dep_tracker: RwLock::new(None), 425 + binary_path: RwLock::new(None), 426 + target_dir, 427 + binary_name, 428 + }), 429 + } 430 + } 431 + } 432 + 433 + #[cfg(test)] 434 + mod tests { 435 + use super::*; 436 + use std::collections::HashMap; 437 + use std::time::SystemTime; 438 + use tempfile::TempDir; 439 + 440 + fn create_test_manager() -> BuildManager { 441 + let status_manager = StatusManager::new(); 442 + BuildManager::new_with_config(status_manager, None, None) 443 + } 444 + 445 + fn create_test_manager_with_config( 446 + target_dir: Option<PathBuf>, 447 + binary_name: Option<String>, 448 + ) -> BuildManager { 449 + let status_manager = StatusManager::new(); 450 + BuildManager::new_with_config(status_manager, target_dir, binary_name) 451 + } 452 + 453 + #[tokio::test] 454 + async fn test_build_manager_clone_shares_state() { 455 + let manager1 = create_test_manager(); 456 + let manager2 = manager1.clone(); 457 + 458 + // Set binary path via one clone 459 + let test_path = PathBuf::from("/test/path"); 460 + manager1.set_binary_path(Some(test_path.clone())).await; 461 + 462 + // Should be visible via the other clone 463 + assert_eq!(manager2.get_binary_path().await, Some(test_path)); 464 + } 465 + 466 + #[tokio::test] 467 + async fn test_needs_recompile_without_tracker() { 468 + let manager = create_test_manager(); 469 + 470 + // Without a dependency tracker, should always return true 471 + let changed = vec![PathBuf::from("src/main.rs")]; 472 + assert!(manager.needs_recompile(&changed).await); 473 + } 474 + 475 + #[tokio::test] 476 + async fn test_needs_recompile_with_empty_tracker() { 477 + let manager = create_test_manager(); 478 + 479 + // Set an empty tracker (no dependencies) 480 + let tracker = DependencyTracker::new(); 481 + manager.set_dep_tracker(Some(tracker)).await; 482 + 483 + // Empty tracker has no dependencies, so has_dependencies() returns false 484 + // This means we should still return true (recompile needed) 485 + let changed = vec![PathBuf::from("src/main.rs")]; 486 + assert!(manager.needs_recompile(&changed).await); 487 + } 488 + 489 + #[tokio::test] 490 + async fn test_needs_recompile_with_matching_dependency() { 491 + let manager = create_test_manager(); 492 + 493 + // Create a tracker with some dependencies 494 + let temp_dir = TempDir::new().unwrap(); 495 + let dep_file = temp_dir.path().join("src/lib.rs"); 496 + std::fs::create_dir_all(dep_file.parent().unwrap()).unwrap(); 497 + std::fs::write(&dep_file, "// test").unwrap(); 498 + 499 + // Get canonical path and current mod time 500 + let canonical_path = dep_file.canonicalize().unwrap(); 501 + let old_time = SystemTime::UNIX_EPOCH; // Very old time 502 + 503 + let mut tracker = DependencyTracker::new(); 504 + tracker.dependencies = HashMap::from([(canonical_path, old_time)]); 505 + 506 + manager.set_dep_tracker(Some(tracker)).await; 507 + 508 + // Changed file IS a dependency and is newer - should need recompile 509 + let changed = vec![dep_file]; 510 + assert!(manager.needs_recompile(&changed).await); 511 + } 512 + 513 + #[tokio::test] 514 + async fn test_needs_recompile_with_non_matching_file() { 515 + let manager = create_test_manager(); 516 + 517 + // Create a tracker with some dependencies 518 + let temp_dir = TempDir::new().unwrap(); 519 + let dep_file = temp_dir.path().join("src/lib.rs"); 520 + std::fs::create_dir_all(dep_file.parent().unwrap()).unwrap(); 521 + std::fs::write(&dep_file, "// test").unwrap(); 522 + 523 + let canonical_path = dep_file.canonicalize().unwrap(); 524 + let mod_time = std::fs::metadata(&dep_file).unwrap().modified().unwrap(); 525 + 526 + let mut tracker = DependencyTracker::new(); 527 + tracker.dependencies = HashMap::from([(canonical_path, mod_time)]); 528 + 529 + manager.set_dep_tracker(Some(tracker)).await; 530 + 531 + // Changed file is NOT a dependency (different file) 532 + let other_file = temp_dir.path().join("assets/style.css"); 533 + std::fs::create_dir_all(other_file.parent().unwrap()).unwrap(); 534 + std::fs::write(&other_file, "/* css */").unwrap(); 535 + 536 + let changed = vec![other_file]; 537 + assert!(!manager.needs_recompile(&changed).await); 538 + } 539 + 540 + #[tokio::test] 541 + async fn test_update_dependency_tracker_with_config_missing_binary() { 542 + let temp_dir = TempDir::new().unwrap(); 543 + let manager = create_test_manager_with_config( 544 + Some(temp_dir.path().to_path_buf()), 545 + Some("nonexistent-binary".to_string()), 546 + ); 547 + 548 + // Binary doesn't exist, so binary_path should not be set 549 + manager.update_dependency_tracker().await; 550 + 551 + assert!(manager.get_binary_path().await.is_none()); 552 + } 553 + 554 + #[tokio::test] 555 + async fn test_update_dependency_tracker_with_existing_binary() { 556 + let temp_dir = TempDir::new().unwrap(); 557 + let binary_name = "test-binary"; 558 + let binary_path = temp_dir.path().join(binary_name); 559 + 560 + // Create a fake binary file 561 + std::fs::write(&binary_path, "fake binary").unwrap(); 562 + 563 + let manager = create_test_manager_with_config( 564 + Some(temp_dir.path().to_path_buf()), 565 + Some(binary_name.to_string()), 566 + ); 567 + 568 + manager.update_dependency_tracker().await; 569 + 570 + // Binary path should be set 571 + assert_eq!(manager.get_binary_path().await, Some(binary_path)); 572 } 573 }
+377
crates/maudit-cli/src/dev/dep_tracker.rs
···
··· 1 + use std::collections::HashMap; 2 + use std::fs; 3 + use std::path::{Path, PathBuf}; 4 + use std::time::SystemTime; 5 + use tracing::{debug, warn}; 6 + 7 + /// Tracks dependencies from .d files to determine if recompilation is needed 8 + #[derive(Debug, Clone)] 9 + pub struct DependencyTracker { 10 + /// Path to the .d file 11 + pub(crate) d_file_path: Option<PathBuf>, 12 + /// Map of dependency paths to their last modification times 13 + pub(crate) dependencies: HashMap<PathBuf, SystemTime>, 14 + } 15 + 16 + /// Find the target directory using multiple strategies 17 + /// 18 + /// This function tries multiple approaches to locate the target directory: 19 + /// 1. CARGO_TARGET_DIR / CARGO_BUILD_TARGET_DIR environment variables 20 + /// 2. Local ./target/debug directory 21 + /// 3. Workspace root target/debug directory (walking up to find [workspace]) 22 + /// 4. Fallback to relative "target/debug" path 23 + pub fn find_target_dir() -> Result<PathBuf, std::io::Error> { 24 + // 1. Check CARGO_TARGET_DIR and CARGO_BUILD_TARGET_DIR environment variables 25 + for env_var in ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"] { 26 + if let Ok(target_dir) = std::env::var(env_var) { 27 + // Try with /debug appended 28 + let path = PathBuf::from(&target_dir).join("debug"); 29 + if path.exists() { 30 + debug!("Using target directory from {}: {:?}", env_var, path); 31 + return Ok(path); 32 + } 33 + // If the env var points directly to debug or release 34 + let path_no_debug = PathBuf::from(&target_dir); 35 + if path_no_debug.exists() 36 + && (path_no_debug.ends_with("debug") || path_no_debug.ends_with("release")) 37 + { 38 + debug!( 39 + "Using target directory from {} (direct): {:?}", 40 + env_var, path_no_debug 41 + ); 42 + return Ok(path_no_debug); 43 + } 44 + } 45 + } 46 + 47 + // 2. Look for target directory in current directory 48 + let local_target = PathBuf::from("target/debug"); 49 + if local_target.exists() { 50 + debug!("Using local target directory: {:?}", local_target); 51 + return Ok(local_target); 52 + } 53 + 54 + // 3. Try to find workspace root by looking for Cargo.toml with [workspace] 55 + let mut current = std::env::current_dir()?; 56 + loop { 57 + let cargo_toml = current.join("Cargo.toml"); 58 + if cargo_toml.exists() 59 + && let Ok(content) = fs::read_to_string(&cargo_toml) 60 + && content.contains("[workspace]") 61 + { 62 + let workspace_target = current.join("target").join("debug"); 63 + if workspace_target.exists() { 64 + debug!("Using workspace target directory: {:?}", workspace_target); 65 + return Ok(workspace_target); 66 + } 67 + } 68 + 69 + // Move up to parent directory 70 + if !current.pop() { 71 + break; 72 + } 73 + } 74 + 75 + // 4. Final fallback to relative path 76 + debug!("Falling back to relative target/debug path"); 77 + Ok(PathBuf::from("target/debug")) 78 + } 79 + 80 + impl DependencyTracker { 81 + #[allow(dead_code)] 82 + pub fn new() -> Self { 83 + Self { 84 + d_file_path: None, 85 + dependencies: HashMap::new(), 86 + } 87 + } 88 + 89 + /// Locate and load the .d file for the current binary 90 + /// The .d file is typically at target/debug/<binary-name>.d 91 + pub fn load_from_binary_name(binary_name: &str) -> Result<Self, std::io::Error> { 92 + let target_dir = find_target_dir()?; 93 + let d_file_path = target_dir.join(format!("{}.d", binary_name)); 94 + 95 + if !d_file_path.exists() { 96 + return Err(std::io::Error::new( 97 + std::io::ErrorKind::NotFound, 98 + format!(".d file not found at {:?}", d_file_path), 99 + )); 100 + } 101 + 102 + let mut tracker = Self { 103 + d_file_path: Some(d_file_path.clone()), 104 + dependencies: HashMap::new(), 105 + }; 106 + 107 + tracker.reload_dependencies()?; 108 + Ok(tracker) 109 + } 110 + 111 + /// Parse space-separated paths from a string, handling escaped spaces 112 + /// In Make-style .d files, spaces in filenames are escaped with backslashes 113 + fn parse_paths(input: &str) -> Vec<PathBuf> { 114 + let mut paths = Vec::new(); 115 + let mut current_path = String::new(); 116 + let mut chars = input.chars().peekable(); 117 + 118 + while let Some(ch) = chars.next() { 119 + match ch { 120 + '\\' => { 121 + // Check if this is escaping a space or newline 122 + if let Some(&next_ch) = chars.peek() { 123 + if next_ch == ' ' { 124 + // Escaped space - add it to the current path 125 + current_path.push(' '); 126 + chars.next(); // consume the space 127 + } else if next_ch == '\n' || next_ch == '\r' { 128 + // Line continuation - skip the backslash and newline 129 + chars.next(); 130 + if next_ch == '\r' { 131 + // Handle \r\n 132 + if chars.peek() == Some(&'\n') { 133 + chars.next(); 134 + } 135 + } 136 + } else { 137 + // Not escaping space or newline, keep the backslash 138 + current_path.push('\\'); 139 + } 140 + } else { 141 + // Backslash at end of string 142 + current_path.push('\\'); 143 + } 144 + } 145 + ' ' | '\t' | '\n' | '\r' => { 146 + // Unescaped whitespace - end current path 147 + if !current_path.is_empty() { 148 + paths.push(PathBuf::from(current_path.clone())); 149 + current_path.clear(); 150 + } 151 + } 152 + _ => { 153 + current_path.push(ch); 154 + } 155 + } 156 + } 157 + 158 + // Don't forget the last path 159 + if !current_path.is_empty() { 160 + paths.push(PathBuf::from(current_path)); 161 + } 162 + 163 + paths 164 + } 165 + 166 + /// Reload dependencies from the .d file 167 + pub fn reload_dependencies(&mut self) -> Result<(), std::io::Error> { 168 + let Some(d_file_path) = &self.d_file_path else { 169 + return Err(std::io::Error::new( 170 + std::io::ErrorKind::NotFound, 171 + "No .d file path set", 172 + )); 173 + }; 174 + 175 + let content = fs::read_to_string(d_file_path)?; 176 + 177 + // Parse the .d file format: "target: dep1 dep2 dep3 ..." 178 + // The first line contains the target and dependencies, separated by ':' 179 + let deps = if let Some(colon_pos) = content.find(':') { 180 + // Everything after the colon is dependencies 181 + &content[colon_pos + 1..] 182 + } else { 183 + // Malformed .d file 184 + warn!("Malformed .d file at {:?}", d_file_path); 185 + return Ok(()); 186 + }; 187 + 188 + // Dependencies are space-separated and may span multiple lines (with line continuations) 189 + // Spaces in filenames are escaped with backslashes 190 + let dep_paths = Self::parse_paths(deps); 191 + 192 + // Clear old dependencies and load new ones with their modification times 193 + self.dependencies.clear(); 194 + 195 + for dep_path in dep_paths { 196 + match fs::metadata(&dep_path) { 197 + Ok(metadata) => { 198 + if let Ok(modified) = metadata.modified() { 199 + self.dependencies.insert(dep_path.clone(), modified); 200 + debug!("Tracking dependency: {:?}", dep_path); 201 + } 202 + } 203 + Err(e) => { 204 + // Dependency file doesn't exist or can't be read - this is okay, 205 + // it might have been deleted or moved 206 + debug!("Could not read dependency {:?}: {}", dep_path, e); 207 + } 208 + } 209 + } 210 + 211 + debug!( 212 + "Loaded {} dependencies from {:?}", 213 + self.dependencies.len(), 214 + d_file_path 215 + ); 216 + Ok(()) 217 + } 218 + 219 + /// Check if any of the given paths require recompilation 220 + /// Returns true if any path is a tracked dependency that has been modified 221 + pub fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool { 222 + for changed_path in changed_paths { 223 + // Normalize the changed path to handle relative vs absolute paths 224 + let changed_path_canonical = changed_path.canonicalize().ok(); 225 + 226 + for (dep_path, last_modified) in &self.dependencies { 227 + // Try to match both exact path and canonical path 228 + let matches = changed_path == dep_path 229 + || changed_path_canonical.as_ref() == Some(dep_path) 230 + || dep_path.canonicalize().ok().as_ref() == changed_path_canonical.as_ref(); 231 + 232 + if matches { 233 + // Check if the file was modified after we last tracked it 234 + if let Ok(metadata) = fs::metadata(changed_path) { 235 + if let Ok(current_modified) = metadata.modified() 236 + && current_modified > *last_modified 237 + { 238 + debug!( 239 + "Dependency {:?} was modified, recompile needed", 240 + changed_path 241 + ); 242 + return true; 243 + } 244 + } else { 245 + // File was deleted or can't be read, assume recompile is needed 246 + debug!( 247 + "Dependency {:?} no longer exists, recompile needed", 248 + changed_path 249 + ); 250 + return true; 251 + } 252 + } 253 + } 254 + } 255 + 256 + false 257 + } 258 + 259 + /// Get the list of tracked dependency paths 260 + pub fn get_dependencies(&self) -> Vec<&Path> { 261 + self.dependencies.keys().map(|p| p.as_path()).collect() 262 + } 263 + 264 + /// Check if we have any dependencies loaded 265 + pub fn has_dependencies(&self) -> bool { 266 + !self.dependencies.is_empty() 267 + } 268 + } 269 + 270 + #[cfg(test)] 271 + mod tests { 272 + use super::*; 273 + use std::fs; 274 + use std::io::Write; 275 + use tempfile::TempDir; 276 + 277 + #[test] 278 + fn test_parse_d_file() { 279 + let temp_dir = TempDir::new().unwrap(); 280 + let d_file_path = temp_dir.path().join("test.d"); 281 + 282 + // Create a mock .d file 283 + let mut d_file = fs::File::create(&d_file_path).unwrap(); 284 + writeln!( 285 + d_file, 286 + "/path/to/target: /path/to/dep1.rs /path/to/dep2.rs \\" 287 + ) 288 + .unwrap(); 289 + writeln!(d_file, " /path/to/dep3.rs").unwrap(); 290 + 291 + // Create a tracker and point it to our test file 292 + let mut tracker = DependencyTracker::new(); 293 + tracker.d_file_path = Some(d_file_path); 294 + 295 + // This will fail to load the actual files, but we can check the parsing logic 296 + let _ = tracker.reload_dependencies(); 297 + 298 + // We won't have any dependencies because the files don't exist, 299 + // but we've verified the parsing doesn't crash 300 + } 301 + 302 + #[test] 303 + fn test_parse_d_file_with_spaces() { 304 + let temp_dir = TempDir::new().unwrap(); 305 + let d_file_path = temp_dir.path().join("test_spaces.d"); 306 + 307 + // Create actual test files with spaces in names 308 + let dep_with_space = temp_dir.path().join("my file.rs"); 309 + fs::write(&dep_with_space, "// test").unwrap(); 310 + 311 + let normal_dep = temp_dir.path().join("normal.rs"); 312 + fs::write(&normal_dep, "// test").unwrap(); 313 + 314 + // Create a mock .d file with escaped spaces (Make format) 315 + let mut d_file = fs::File::create(&d_file_path).unwrap(); 316 + writeln!( 317 + d_file, 318 + "/path/to/target: {} {}", 319 + dep_with_space.to_str().unwrap().replace(' ', "\\ "), 320 + normal_dep.to_str().unwrap() 321 + ) 322 + .unwrap(); 323 + 324 + let mut tracker = DependencyTracker::new(); 325 + tracker.d_file_path = Some(d_file_path); 326 + 327 + // Load dependencies 328 + tracker.reload_dependencies().unwrap(); 329 + 330 + // Should have successfully parsed both files 331 + assert!(tracker.has_dependencies()); 332 + let deps = tracker.get_dependencies(); 333 + assert_eq!(deps.len(), 2); 334 + assert!( 335 + deps.iter() 336 + .any(|p| p.to_str().unwrap().contains("my file.rs")), 337 + "Should contain file with space" 338 + ); 339 + assert!( 340 + deps.iter() 341 + .any(|p| p.to_str().unwrap().contains("normal.rs")), 342 + "Should contain normal file" 343 + ); 344 + } 345 + 346 + #[test] 347 + fn test_parse_escaped_paths() { 348 + // Test basic space-separated paths 349 + let paths = DependencyTracker::parse_paths("a.rs b.rs c.rs"); 350 + assert_eq!(paths.len(), 3); 351 + assert_eq!(paths[0], PathBuf::from("a.rs")); 352 + assert_eq!(paths[1], PathBuf::from("b.rs")); 353 + assert_eq!(paths[2], PathBuf::from("c.rs")); 354 + 355 + // Test escaped spaces 356 + let paths = DependencyTracker::parse_paths("my\\ file.rs another.rs"); 357 + assert_eq!(paths.len(), 2); 358 + assert_eq!(paths[0], PathBuf::from("my file.rs")); 359 + assert_eq!(paths[1], PathBuf::from("another.rs")); 360 + 361 + // Test line continuation 362 + let paths = DependencyTracker::parse_paths("a.rs b.rs \\\nc.rs"); 363 + assert_eq!(paths.len(), 3); 364 + assert_eq!(paths[0], PathBuf::from("a.rs")); 365 + assert_eq!(paths[1], PathBuf::from("b.rs")); 366 + assert_eq!(paths[2], PathBuf::from("c.rs")); 367 + 368 + // Test multiple escaped spaces 369 + let paths = DependencyTracker::parse_paths("path/to/my\\ file\\ name.rs"); 370 + assert_eq!(paths.len(), 1); 371 + assert_eq!(paths[0], PathBuf::from("path/to/my file name.rs")); 372 + 373 + // Test mixed whitespace 374 + let paths = DependencyTracker::parse_paths("a.rs\tb.rs\nc.rs"); 375 + assert_eq!(paths.len(), 3); 376 + } 377 + }
+223 -64
crates/maudit-cli/src/dev/server.rs
··· 64 pub message: String, 65 } 66 67 #[derive(Clone)] 68 - struct AppState { 69 tx: broadcast::Sender<WebSocketMessage>, 70 current_status: Arc<RwLock<Option<PersistentStatus>>>, 71 } 72 73 fn inject_live_reload_script(html_content: &str, socket_addr: SocketAddr, host: bool) -> String { ··· 93 94 pub async fn start_dev_web_server( 95 start_time: Instant, 96 - tx: broadcast::Sender<WebSocketMessage>, 97 host: bool, 98 port: Option<u16>, 99 initial_error: Option<String>, 100 - current_status: Arc<RwLock<Option<PersistentStatus>>>, 101 ) { 102 // TODO: The dist dir should be configurable 103 let dist_dir = "dist"; 104 105 // Send initial error if present 106 if let Some(error) = initial_error { 107 - let _ = tx.send(WebSocketMessage { 108 data: json!({ 109 "type": StatusType::Error.to_string(), 110 "message": error ··· 172 .on_response(CustomOnResponse), 173 ) 174 .with_state(AppState { 175 - tx: tx.clone(), 176 - current_status: current_status.clone(), 177 }); 178 179 log_server_start( ··· 192 .unwrap(); 193 } 194 195 - pub async fn update_status( 196 - tx: &broadcast::Sender<WebSocketMessage>, 197 - current_status: Arc<RwLock<Option<PersistentStatus>>>, 198 - status_type: StatusType, 199 - message: &str, 200 - ) { 201 - // Only store persistent states (Success clears errors, Error stores the error) 202 - let persistent_status = match status_type { 203 - StatusType::Success => None, // Clear any error state 204 - StatusType::Error => Some(PersistentStatus { 205 - status_type: StatusType::Error, 206 - message: message.to_string(), 207 - }), 208 - // Everything else just keeps the current state 209 - _ => { 210 - let status = current_status.read().await; 211 - status.clone() // Keep existing persistent state 212 - } 213 - }; 214 - 215 - // Update the stored status 216 - { 217 - let mut status = current_status.write().await; 218 - *status = persistent_status; 219 - } 220 - 221 - // Send the message to all connected clients 222 - let _ = tx.send(WebSocketMessage { 223 - data: json!({ 224 - "type": status_type.to_string(), 225 - "message": message 226 - }) 227 - .to_string(), 228 - }); 229 - } 230 - 231 async fn add_dev_client_script( 232 req: Request, 233 next: Next, ··· 311 debug!("`{addr} connected."); 312 // finalize the upgrade process by returning upgrade callback. 313 // we can customize the callback by sending additional info such as address. 314 - ws.on_upgrade(move |socket| handle_socket(socket, addr, state.tx, state.current_status)) 315 } 316 317 - async fn handle_socket( 318 - socket: WebSocket, 319 - who: SocketAddr, 320 - tx: broadcast::Sender<WebSocketMessage>, 321 - current_status: Arc<RwLock<Option<PersistentStatus>>>, 322 - ) { 323 let (mut sender, mut receiver) = socket.split(); 324 325 // Send current persistent status to new connection if there is one 326 - { 327 - let status = current_status.read().await; 328 - if let Some(persistent_status) = status.as_ref() { 329 - let _ = sender 330 - .send(Message::Text( 331 - json!({ 332 - "type": persistent_status.status_type.to_string(), 333 - "message": persistent_status.message 334 - }) 335 - .to_string() 336 - .into(), 337 - )) 338 - .await; 339 - } 340 } 341 342 - let mut rx = tx.subscribe(); 343 344 tokio::select! { 345 _ = async { ··· 387 _ = terminate => {}, 388 } 389 }
··· 64 pub message: String, 65 } 66 67 + /// Manages status updates and WebSocket broadcasting. 68 + /// Cheap to clone - all clones share the same underlying state. 69 #[derive(Clone)] 70 + pub struct StatusManager { 71 tx: broadcast::Sender<WebSocketMessage>, 72 current_status: Arc<RwLock<Option<PersistentStatus>>>, 73 + } 74 + 75 + impl StatusManager { 76 + pub fn new() -> Self { 77 + let (tx, _) = broadcast::channel::<WebSocketMessage>(100); 78 + Self { 79 + tx, 80 + current_status: Arc::new(RwLock::new(None)), 81 + } 82 + } 83 + 84 + /// Update the status and broadcast to all connected WebSocket clients. 85 + pub async fn update(&self, status_type: StatusType, message: &str) { 86 + // Only store persistent states (Success clears errors, Error stores the error) 87 + let persistent_status = match status_type { 88 + StatusType::Success => None, // Clear any error state 89 + StatusType::Error => Some(PersistentStatus { 90 + status_type: StatusType::Error, 91 + message: message.to_string(), 92 + }), 93 + // Everything else just keeps the current state 94 + _ => { 95 + let status = self.current_status.read().await; 96 + status.clone() // Keep existing persistent state 97 + } 98 + }; 99 + 100 + // Update the stored status 101 + { 102 + let mut status = self.current_status.write().await; 103 + *status = persistent_status; 104 + } 105 + 106 + // Send the message to all connected clients 107 + let _ = self.tx.send(WebSocketMessage { 108 + data: json!({ 109 + "type": status_type.to_string(), 110 + "message": message 111 + }) 112 + .to_string(), 113 + }); 114 + } 115 + 116 + /// Subscribe to WebSocket messages (for new connections). 117 + pub fn subscribe(&self) -> broadcast::Receiver<WebSocketMessage> { 118 + self.tx.subscribe() 119 + } 120 + 121 + /// Get the current persistent status (for new connections). 122 + pub async fn get_current(&self) -> Option<PersistentStatus> { 123 + self.current_status.read().await.clone() 124 + } 125 + 126 + /// Send a raw WebSocket message (for initial errors, etc.). 127 + pub fn send_raw(&self, message: WebSocketMessage) { 128 + let _ = self.tx.send(message); 129 + } 130 + } 131 + 132 + impl Default for StatusManager { 133 + fn default() -> Self { 134 + Self::new() 135 + } 136 + } 137 + 138 + #[derive(Clone)] 139 + struct AppState { 140 + status_manager: StatusManager, 141 } 142 143 fn inject_live_reload_script(html_content: &str, socket_addr: SocketAddr, host: bool) -> String { ··· 163 164 pub async fn start_dev_web_server( 165 start_time: Instant, 166 + status_manager: StatusManager, 167 host: bool, 168 port: Option<u16>, 169 initial_error: Option<String>, 170 ) { 171 // TODO: The dist dir should be configurable 172 let dist_dir = "dist"; 173 174 // Send initial error if present 175 if let Some(error) = initial_error { 176 + status_manager.send_raw(WebSocketMessage { 177 data: json!({ 178 "type": StatusType::Error.to_string(), 179 "message": error ··· 241 .on_response(CustomOnResponse), 242 ) 243 .with_state(AppState { 244 + status_manager: status_manager.clone(), 245 }); 246 247 log_server_start( ··· 260 .unwrap(); 261 } 262 263 async fn add_dev_client_script( 264 req: Request, 265 next: Next, ··· 343 debug!("`{addr} connected."); 344 // finalize the upgrade process by returning upgrade callback. 345 // we can customize the callback by sending additional info such as address. 346 + ws.on_upgrade(move |socket| handle_socket(socket, addr, state.status_manager)) 347 } 348 349 + async fn handle_socket(socket: WebSocket, who: SocketAddr, status_manager: StatusManager) { 350 let (mut sender, mut receiver) = socket.split(); 351 352 // Send current persistent status to new connection if there is one 353 + if let Some(persistent_status) = status_manager.get_current().await { 354 + let _ = sender 355 + .send(Message::Text( 356 + json!({ 357 + "type": persistent_status.status_type.to_string(), 358 + "message": persistent_status.message 359 + }) 360 + .to_string() 361 + .into(), 362 + )) 363 + .await; 364 } 365 366 + let mut rx = status_manager.subscribe(); 367 368 tokio::select! { 369 _ = async { ··· 411 _ = terminate => {}, 412 } 413 } 414 + 415 + #[cfg(test)] 416 + mod tests { 417 + use super::*; 418 + 419 + #[tokio::test] 420 + async fn test_status_manager_update_error_persists() { 421 + let manager = StatusManager::new(); 422 + 423 + manager 424 + .update(StatusType::Error, "Something went wrong") 425 + .await; 426 + 427 + let status = manager.get_current().await; 428 + assert!(status.is_some()); 429 + let status = status.unwrap(); 430 + assert!(matches!(status.status_type, StatusType::Error)); 431 + assert_eq!(status.message, "Something went wrong"); 432 + } 433 + 434 + #[tokio::test] 435 + async fn test_status_manager_update_success_clears_error() { 436 + let manager = StatusManager::new(); 437 + 438 + // First set an error 439 + manager.update(StatusType::Error, "Build failed").await; 440 + assert!(manager.get_current().await.is_some()); 441 + 442 + // Then send success - should clear the error 443 + manager.update(StatusType::Success, "Build succeeded").await; 444 + assert!(manager.get_current().await.is_none()); 445 + } 446 + 447 + #[tokio::test] 448 + async fn test_status_manager_update_info_preserves_state() { 449 + let manager = StatusManager::new(); 450 + 451 + // Set an error 452 + manager.update(StatusType::Error, "Build failed").await; 453 + let original_status = manager.get_current().await; 454 + assert!(original_status.is_some()); 455 + 456 + // Send info - should preserve the error state 457 + manager.update(StatusType::Info, "Building...").await; 458 + let status = manager.get_current().await; 459 + assert!(status.is_some()); 460 + assert_eq!(status.unwrap().message, "Build failed"); 461 + } 462 + 463 + #[tokio::test] 464 + async fn test_status_manager_update_info_when_no_error() { 465 + let manager = StatusManager::new(); 466 + 467 + // No prior state 468 + assert!(manager.get_current().await.is_none()); 469 + 470 + // Send info - should remain None 471 + manager.update(StatusType::Info, "Building...").await; 472 + assert!(manager.get_current().await.is_none()); 473 + } 474 + 475 + #[tokio::test] 476 + async fn test_status_manager_subscribe_receives_messages() { 477 + let manager = StatusManager::new(); 478 + let mut rx = manager.subscribe(); 479 + 480 + manager.update(StatusType::Info, "Hello").await; 481 + 482 + let msg = rx.try_recv(); 483 + assert!(msg.is_ok()); 484 + let msg = msg.unwrap(); 485 + assert!(msg.data.contains("Hello")); 486 + assert!(msg.data.contains("info")); 487 + } 488 + 489 + #[tokio::test] 490 + async fn test_status_manager_multiple_subscribers() { 491 + let manager = StatusManager::new(); 492 + let mut rx1 = manager.subscribe(); 493 + let mut rx2 = manager.subscribe(); 494 + 495 + manager.update(StatusType::Success, "Done").await; 496 + 497 + // Both subscribers should receive the message 498 + assert!(rx1.try_recv().is_ok()); 499 + assert!(rx2.try_recv().is_ok()); 500 + } 501 + 502 + #[tokio::test] 503 + async fn test_status_manager_send_raw() { 504 + let manager = StatusManager::new(); 505 + let mut rx = manager.subscribe(); 506 + 507 + manager.send_raw(WebSocketMessage { 508 + data: r#"{"custom": "message"}"#.to_string(), 509 + }); 510 + 511 + let msg = rx.try_recv(); 512 + assert!(msg.is_ok()); 513 + assert_eq!(msg.unwrap().data, r#"{"custom": "message"}"#); 514 + } 515 + 516 + #[tokio::test] 517 + async fn test_status_manager_clone_shares_state() { 518 + let manager1 = StatusManager::new(); 519 + let manager2 = manager1.clone(); 520 + 521 + // Update via one clone 522 + manager1 523 + .update(StatusType::Error, "Error from clone 1") 524 + .await; 525 + 526 + // Should be visible via the other clone 527 + let status = manager2.get_current().await; 528 + assert!(status.is_some()); 529 + assert_eq!(status.unwrap().message, "Error from clone 1"); 530 + } 531 + 532 + #[tokio::test] 533 + async fn test_status_manager_clone_shares_broadcast() { 534 + let manager1 = StatusManager::new(); 535 + let manager2 = manager1.clone(); 536 + 537 + // Subscribe via one clone 538 + let mut rx = manager2.subscribe(); 539 + 540 + // Send via the other clone 541 + manager1.update(StatusType::Info, "From clone 1").await; 542 + 543 + // Should receive the message 544 + let msg = rx.try_recv(); 545 + assert!(msg.is_ok()); 546 + assert!(msg.unwrap().data.contains("From clone 1")); 547 + } 548 + }
+65 -27
crates/maudit-cli/src/dev.rs
··· 1 pub(crate) mod server; 2 3 mod build; 4 mod filterer; 5 6 use notify::{ ··· 9 }; 10 use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; 11 use quanta::Instant; 12 - use server::WebSocketMessage; 13 - use std::{fs, path::Path}; 14 - use tokio::{ 15 - signal, 16 - sync::{broadcast, mpsc::channel}, 17 - task::JoinHandle, 18 }; 19 use tracing::{error, info}; 20 21 use crate::dev::build::BuildManager; 22 23 - pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> { 24 let start_time = Instant::now(); 25 info!(name: "dev", "Preparing dev environmentโ€ฆ"); 26 27 - let (sender_websocket, _) = broadcast::channel::<WebSocketMessage>(100); 28 29 - // Create build manager (it will create its own status state internally) 30 - let build_manager = BuildManager::new(sender_websocket.clone()); 31 32 // Do initial build 33 info!(name: "build", "Doing initial buildโ€ฆ"); ··· 48 .collect::<Vec<_>>(); 49 50 let mut debouncer = new_debouncer( 51 - std::time::Duration::from_millis(100), 52 None, 53 move |result: DebounceEventResult| { 54 tx.blocking_send(result).unwrap_or(()); ··· 73 info!(name: "dev", "Starting web server..."); 74 web_server_thread = Some(tokio::spawn(server::start_dev_web_server( 75 start_time, 76 - sender_websocket.clone(), 77 host, 78 port, 79 None, 80 - build_manager.current_status(), 81 ))); 82 } 83 84 // Clone build manager for the file watcher task 85 let build_manager_watcher = build_manager.clone(); 86 - let sender_websocket_watcher = sender_websocket.clone(); 87 88 let file_watcher_task = tokio::spawn(async move { 89 let mut dev_server_started = initial_build_success; ··· 147 dev_server_handle = 148 Some(tokio::spawn(server::start_dev_web_server( 149 start_time, 150 - sender_websocket_watcher.clone(), 151 host, 152 port, 153 None, 154 - build_manager_watcher.current_status(), 155 ))); 156 } 157 Ok(false) => { ··· 162 } 163 } 164 } else { 165 - // Normal rebuild - spawn in background so file watcher can continue 166 - info!(name: "watch", "Files changed, rebuilding..."); 167 - let build_manager_clone = build_manager_watcher.clone(); 168 - tokio::spawn(async move { 169 - match build_manager_clone.start_build().await { 170 - Ok(_) => { 171 - // Build completed (success or failure already logged) 172 } 173 - Err(e) => { 174 - error!(name: "build", "Failed to start build: {}", e); 175 } 176 - } 177 - }); 178 } 179 } 180 }
··· 1 pub(crate) mod server; 2 3 mod build; 4 + mod dep_tracker; 5 mod filterer; 6 7 use notify::{ ··· 10 }; 11 use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; 12 use quanta::Instant; 13 + use server::StatusManager; 14 + use std::{ 15 + fs, 16 + path::{Path, PathBuf}, 17 }; 18 + use tokio::{signal, sync::mpsc::channel, task::JoinHandle}; 19 use tracing::{error, info}; 20 21 use crate::dev::build::BuildManager; 22 23 + pub async fn start_dev_env( 24 + cwd: &str, 25 + host: bool, 26 + port: Option<u16>, 27 + ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { 28 let start_time = Instant::now(); 29 info!(name: "dev", "Preparing dev environmentโ€ฆ"); 30 31 + // Create status manager (handles WebSocket communication) 32 + let status_manager = StatusManager::new(); 33 34 + // Create build manager 35 + let build_manager = BuildManager::new(status_manager.clone()); 36 37 // Do initial build 38 info!(name: "build", "Doing initial buildโ€ฆ"); ··· 53 .collect::<Vec<_>>(); 54 55 let mut debouncer = new_debouncer( 56 + std::time::Duration::from_millis(200), // Longer debounce to better batch rapid file changes 57 None, 58 move |result: DebounceEventResult| { 59 tx.blocking_send(result).unwrap_or(()); ··· 78 info!(name: "dev", "Starting web server..."); 79 web_server_thread = Some(tokio::spawn(server::start_dev_web_server( 80 start_time, 81 + status_manager.clone(), 82 host, 83 port, 84 None, 85 ))); 86 } 87 88 // Clone build manager for the file watcher task 89 let build_manager_watcher = build_manager.clone(); 90 + let status_manager_watcher = status_manager.clone(); 91 92 let file_watcher_task = tokio::spawn(async move { 93 let mut dev_server_started = initial_build_success; ··· 151 dev_server_handle = 152 Some(tokio::spawn(server::start_dev_web_server( 153 start_time, 154 + status_manager_watcher.clone(), 155 host, 156 port, 157 None, 158 ))); 159 } 160 Ok(false) => { ··· 165 } 166 } 167 } else { 168 + // Normal rebuild - check if we need full recompilation or just rerun 169 + // Only collect paths from events that actually trigger a rebuild 170 + let mut changed_paths: Vec<PathBuf> = events.iter() 171 + .filter(|e| should_rebuild_for_event(e)) 172 + .flat_map(|e| e.paths.iter().cloned()) 173 + .collect(); 174 + 175 + // Deduplicate paths 176 + changed_paths.sort(); 177 + changed_paths.dedup(); 178 + 179 + if changed_paths.is_empty() { 180 + // No file changes, only directory changes - skip rebuild 181 + continue; 182 + } 183 + 184 + let needs_recompile = build_manager_watcher.needs_recompile(&changed_paths).await; 185 + 186 + if needs_recompile { 187 + // Need to recompile - spawn in background so file watcher can continue 188 + info!(name: "watch", "Files changed, rebuilding..."); 189 + let build_manager_clone = build_manager_watcher.clone(); 190 + tokio::spawn(async move { 191 + match build_manager_clone.start_build().await { 192 + Ok(_) => { 193 + // Build completed (success or failure already logged) 194 + } 195 + Err(e) => { 196 + error!(name: "build", "Failed to start build: {}", e); 197 + } 198 } 199 + }); 200 + } else { 201 + // Just rerun the binary without recompiling 202 + info!(name: "watch", "Non-dependency files changed, rerunning binary..."); 203 + let build_manager_clone = build_manager_watcher.clone(); 204 + let changed_paths_clone = changed_paths.clone(); 205 + tokio::spawn(async move { 206 + match build_manager_clone.rerun_binary(&changed_paths_clone).await { 207 + Ok(_) => { 208 + // Rerun completed (success or failure already logged) 209 + } 210 + Err(e) => { 211 + error!(name: "build", "Failed to rerun binary: {}", e); 212 + } 213 } 214 + }); 215 + } 216 } 217 } 218 }
+3
e2e/README.md
··· 13 ## Running Tests 14 15 The tests will automatically: 16 1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`) 17 2. Start the Maudit dev server on the test fixture site 18 3. Run the tests ··· 46 ## Features Tested 47 48 ### Basic Prefetch 49 - Creating link elements with `rel="prefetch"` 50 - Preventing duplicate prefetches 51 - Skipping current page prefetch 52 - Blocking cross-origin prefetches 53 54 ### Prerendering (Chromium only) 55 - 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 14 15 The tests will automatically: 16 + 17 1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`) 18 2. Start the Maudit dev server on the test fixture site 19 3. Run the tests ··· 47 ## Features Tested 48 49 ### Basic Prefetch 50 + 51 - Creating link elements with `rel="prefetch"` 52 - Preventing duplicate prefetches 53 - Skipping current page prefetch 54 - Blocking cross-origin prefetches 55 56 ### 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
e2e/fixtures/hot-reload/data.txt
···
··· 1 + Test data
+1 -1
e2e/fixtures/hot-reload/src/main.rs
··· 1 - use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 3 mod pages { 4 mod index;
··· 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 3 mod pages { 4 mod index;
+9
e2e/fixtures/incremental-build/Cargo.toml
···
··· 1 + [package] 2 + name = "fixtures-incremental-build" 3 + version = "0.1.0" 4 + edition = "2024" 5 + publish = false 6 + 7 + [dependencies] 8 + maudit.workspace = true 9 + maud.workspace = true
+2
e2e/fixtures/incremental-build/src/assets/about.js
···
··· 1 + // About script 2 + console.log("About script loaded");
e2e/fixtures/incremental-build/src/assets/bg.png

This is a binary file and will not be displayed.

+10
e2e/fixtures/incremental-build/src/assets/blog.css
···
··· 1 + /* Blog styles */ 2 + .blog-post { 3 + margin: 20px; 4 + } 5 + 6 + /* Background image referenced via url() - tests CSS asset dependency tracking */ 7 + .blog-header { 8 + background-image: url("./bg.png"); 9 + background-size: cover; 10 + }
+8
e2e/fixtures/incremental-build/src/assets/icons/blog-icon.css
···
··· 1 + /* Blog icon styles */ 2 + .blog-icon { 3 + width: 24px; 4 + height: 24px; 5 + display: inline-block; 6 + } 7 + 8 + /* init */
e2e/fixtures/incremental-build/src/assets/logo.png

This is a binary file and will not be displayed.

+5
e2e/fixtures/incremental-build/src/assets/main.js
···
··· 1 + // Main script 2 + import { greet } from "./utils.js"; 3 + 4 + console.log("Main script loaded"); 5 + console.log(greet("World"));
+13
e2e/fixtures/incremental-build/src/assets/styles.css
···
··· 1 + /* Main styles */ 2 + body { 3 + font-family: sans-serif; 4 + } 5 + /* test7 */ 6 + /* test */ 7 + /* test2 */ 8 + /* test4 */ 9 + /* change1 */ 10 + /* change1 */ 11 + /* change1 */ 12 + /* change1 */ 13 + /* change1 */
e2e/fixtures/incremental-build/src/assets/team.png

This is a binary file and will not be displayed.

+4
e2e/fixtures/incremental-build/src/assets/utils.js
···
··· 1 + // Utility functions 2 + export function greet(name) { 3 + return `Hello, ${name}!`; 4 + }
+11
e2e/fixtures/incremental-build/src/main.rs
···
··· 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 + 3 + mod pages; 4 + 5 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 6 + coronate( 7 + routes![pages::index::Index, pages::about::About, pages::blog::Blog], 8 + content_sources![], 9 + BuildOptions::default(), 10 + ) 11 + }
+33
e2e/fixtures/incremental-build/src/pages/about.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + #[route("/about")] 6 + pub struct About; 7 + 8 + impl Route for About { 9 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 + let _image = ctx.assets.add_image("src/assets/team.png"); 11 + let _script = ctx.assets.add_script("src/assets/about.js"); 12 + // Shared style with index page (for testing shared assets) 13 + let _style = ctx.assets.add_style("src/assets/styles.css"); 14 + 15 + // Generate a unique build ID - uses nanoseconds for uniqueness 16 + let build_id = SystemTime::now() 17 + .duration_since(UNIX_EPOCH) 18 + .map(|d| d.as_nanos().to_string()) 19 + .unwrap_or_else(|_| "0".to_string()); 20 + 21 + html! { 22 + html { 23 + head { 24 + title { "About Page" } 25 + } 26 + body data-build-id=(build_id) { 27 + h1 id="title" { "About Us" } 28 + p id="content" { "Learn more about us" } 29 + } 30 + } 31 + } 32 + } 33 + }
+31
e2e/fixtures/incremental-build/src/pages/blog.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + #[route("/blog")] 6 + pub struct Blog; 7 + 8 + impl Route for Blog { 9 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 + let _style = ctx.assets.add_style("src/assets/blog.css"); 11 + let _icon_style = ctx.assets.add_style("src/assets/icons/blog-icon.css"); 12 + 13 + // Generate a unique build ID - uses nanoseconds for uniqueness 14 + let build_id = SystemTime::now() 15 + .duration_since(UNIX_EPOCH) 16 + .map(|d| d.as_nanos().to_string()) 17 + .unwrap_or_else(|_| "0".to_string()); 18 + 19 + html! { 20 + html { 21 + head { 22 + title { "Blog Page" } 23 + } 24 + body data-build-id=(build_id) { 25 + h1 id="title" { "Blog" } 26 + p id="content" { "Read our latest posts" } 27 + } 28 + } 29 + } 30 + } 31 + }
+32
e2e/fixtures/incremental-build/src/pages/index.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + #[route("/")] 6 + pub struct Index; 7 + 8 + impl Route for Index { 9 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 + let _image = ctx.assets.add_image("src/assets/logo.png"); 11 + let _script = ctx.assets.add_script("src/assets/main.js"); 12 + let _style = ctx.assets.add_style("src/assets/styles.css"); 13 + 14 + // Generate a unique build ID - uses nanoseconds for uniqueness 15 + let build_id = SystemTime::now() 16 + .duration_since(UNIX_EPOCH) 17 + .map(|d| d.as_nanos().to_string()) 18 + .unwrap_or_else(|_| "0".to_string()); 19 + 20 + html! { 21 + html { 22 + head { 23 + title { "Home Page" } 24 + } 25 + body data-build-id=(build_id) { 26 + h1 id="title" { "Home Page" } 27 + p id="content" { "Welcome to the home page" } 28 + } 29 + } 30 + } 31 + } 32 + }
+3
e2e/fixtures/incremental-build/src/pages/mod.rs
···
··· 1 + pub mod about; 2 + pub mod blog; 3 + pub mod index;
+1 -1
e2e/fixtures/prefetch-prerender/src/main.rs
··· 1 - use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 3 mod pages { 4 mod about;
··· 1 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 2 3 mod pages { 4 mod about;
+116 -8
e2e/tests/hot-reload.spec.ts
··· 12 13 test.describe.configure({ mode: "serial" }); 14 15 test.describe("Hot Reload", () => { 16 const fixturePath = resolve(__dirname, "..", "fixtures", "hot-reload"); 17 const indexPath = resolve(fixturePath, "src", "pages", "index.rs"); 18 - let originalContent: string; 19 20 test.beforeAll(async () => { 21 // Save original content 22 - originalContent = readFileSync(indexPath, "utf-8"); 23 }); 24 25 - test.afterEach(async () => { 26 // Restore original content after each test 27 - writeFileSync(indexPath, originalContent, "utf-8"); 28 - // Wait a bit for the rebuild 29 - await new Promise((resolve) => setTimeout(resolve, 2000)); 30 }); 31 32 test.afterAll(async () => { 33 // Restore original content 34 - writeFileSync(indexPath, originalContent, "utf-8"); 35 }); 36 37 test("should show updated content after file changes", async ({ page, devServer }) => { ··· 44 const currentUrl = page.url(); 45 46 // Modify the file 47 - const modifiedContent = originalContent.replace( 48 'h1 id="title" { "Original Title" }', 49 'h1 id="title" { "Another Update" }', 50 );
··· 12 13 test.describe.configure({ mode: "serial" }); 14 15 + /** 16 + * Wait for dev server to complete a build/rerun by polling logs 17 + */ 18 + async function waitForBuildComplete(devServer: any, timeoutMs = 20000): Promise<string[]> { 19 + const startTime = Date.now(); 20 + 21 + while (Date.now() - startTime < timeoutMs) { 22 + const logs = devServer.getLogs(100); 23 + const logsText = logs.join("\n").toLowerCase(); 24 + 25 + // Look for completion messages 26 + if ( 27 + logsText.includes("finished") || 28 + logsText.includes("rerun finished") || 29 + logsText.includes("build finished") 30 + ) { 31 + return logs; 32 + } 33 + 34 + // Wait 100ms before checking again 35 + await new Promise((resolve) => setTimeout(resolve, 100)); 36 + } 37 + 38 + throw new Error(`Build did not complete within ${timeoutMs}ms`); 39 + } 40 + 41 test.describe("Hot Reload", () => { 42 + // Increase timeout for these tests since they involve compilation 43 + test.setTimeout(60000); 44 + 45 const fixturePath = resolve(__dirname, "..", "fixtures", "hot-reload"); 46 const indexPath = resolve(fixturePath, "src", "pages", "index.rs"); 47 + const mainPath = resolve(fixturePath, "src", "main.rs"); 48 + const dataPath = resolve(fixturePath, "data.txt"); 49 + let originalIndexContent: string; 50 + let originalMainContent: string; 51 + let originalDataContent: string; 52 53 test.beforeAll(async () => { 54 // Save original content 55 + originalIndexContent = readFileSync(indexPath, "utf-8"); 56 + originalMainContent = readFileSync(mainPath, "utf-8"); 57 + originalDataContent = readFileSync(dataPath, "utf-8"); 58 + 59 + // Ensure files are in original state 60 + writeFileSync(indexPath, originalIndexContent, "utf-8"); 61 + writeFileSync(mainPath, originalMainContent, "utf-8"); 62 + writeFileSync(dataPath, originalDataContent, "utf-8"); 63 }); 64 65 + test.afterEach(async ({ devServer }) => { 66 // Restore original content after each test 67 + writeFileSync(indexPath, originalIndexContent, "utf-8"); 68 + writeFileSync(mainPath, originalMainContent, "utf-8"); 69 + writeFileSync(dataPath, originalDataContent, "utf-8"); 70 + 71 + // Only wait for build if devServer is available (startup might have failed) 72 + if (devServer) { 73 + try { 74 + devServer.clearLogs(); 75 + await waitForBuildComplete(devServer); 76 + } catch (error) { 77 + console.warn("Failed to wait for build completion in afterEach:", error); 78 + } 79 + } 80 }); 81 82 test.afterAll(async () => { 83 // Restore original content 84 + writeFileSync(indexPath, originalIndexContent, "utf-8"); 85 + writeFileSync(mainPath, originalMainContent, "utf-8"); 86 + writeFileSync(dataPath, originalDataContent, "utf-8"); 87 + }); 88 + 89 + test("should recompile when Rust code changes (dependencies)", async ({ page, devServer }) => { 90 + await page.goto(devServer.url); 91 + 92 + // Verify initial content 93 + await expect(page.locator("#title")).toHaveText("Original Title"); 94 + 95 + // Clear logs to track what happens after this point 96 + devServer.clearLogs(); 97 + 98 + // Modify main.rs - this is a tracked dependency, should trigger recompile 99 + const modifiedMain = originalMainContent.replace( 100 + "BuildOptions::default()", 101 + "BuildOptions::default() // Modified comment", 102 + ); 103 + writeFileSync(mainPath, modifiedMain, "utf-8"); 104 + 105 + // Wait for rebuild to complete 106 + const logs = await waitForBuildComplete(devServer, 20000); 107 + const logsText = logs.join("\n"); 108 + 109 + // Check logs to verify it actually recompiled (ran cargo) 110 + expect(logsText).toContain("rebuilding"); 111 + // Make sure it didn't just rerun the binary 112 + expect(logsText.toLowerCase()).not.toContain("rerunning binary"); 113 + }); 114 + 115 + test("should rerun without recompile when non-dependency files change", async ({ 116 + page, 117 + devServer, 118 + }) => { 119 + await page.goto(devServer.url); 120 + 121 + // Verify initial content 122 + await expect(page.locator("#title")).toHaveText("Original Title"); 123 + 124 + // Clear logs to track what happens after this point 125 + devServer.clearLogs(); 126 + 127 + // Modify data.txt - this file is NOT in the .d dependencies 128 + // So it should trigger a rerun without recompilation 129 + writeFileSync(dataPath, "Modified data", "utf-8"); 130 + 131 + // Wait for build/rerun to complete 132 + const logs = await waitForBuildComplete(devServer, 20000); 133 + const logsText = logs.join("\n"); 134 + 135 + // Should see "rerunning binary" message (case insensitive) 136 + const hasRerunMessage = logsText.toLowerCase().includes("rerunning binary"); 137 + expect(hasRerunMessage).toBe(true); 138 + 139 + // Should NOT see cargo-related rebuild messages (compiling, building crate) 140 + // Note: "Rebuilding N affected routes" is fine - that's the incremental build system 141 + expect(logsText.toLowerCase()).not.toContain("compiling"); 142 + expect(logsText.toLowerCase()).not.toContain("cargo build"); 143 }); 144 145 test("should show updated content after file changes", async ({ page, devServer }) => { ··· 152 const currentUrl = page.url(); 153 154 // Modify the file 155 + const modifiedContent = originalIndexContent.replace( 156 'h1 id="title" { "Original Title" }', 157 'h1 id="title" { "Another Update" }', 158 );
+521
e2e/tests/incremental-build.spec.ts
···
··· 1 + import { expect } from "@playwright/test"; 2 + import { createTestWithFixture } from "./test-utils"; 3 + import { readFileSync, writeFileSync, renameSync, rmSync, 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 + // 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 looking for specific patterns. 18 + * Waits for the build to START, then waits for it to FINISH. 19 + */ 20 + async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> { 21 + const startTime = Date.now(); 22 + 23 + // Phase 1: Wait for build to start 24 + while (Date.now() - startTime < timeoutMs) { 25 + const logs = devServer.getLogs(200); 26 + const logsText = logs.join("\n").toLowerCase(); 27 + 28 + if ( 29 + logsText.includes("rerunning") || 30 + logsText.includes("rebuilding") || 31 + logsText.includes("files changed") 32 + ) { 33 + break; 34 + } 35 + 36 + await new Promise((resolve) => setTimeout(resolve, 50)); 37 + } 38 + 39 + // Phase 2: Wait for build to finish 40 + while (Date.now() - startTime < timeoutMs) { 41 + const logs = devServer.getLogs(200); 42 + const logsText = logs.join("\n").toLowerCase(); 43 + 44 + if ( 45 + logsText.includes("finished") || 46 + logsText.includes("rerun finished") || 47 + logsText.includes("build finished") 48 + ) { 49 + // Wait for filesystem to fully sync 50 + await new Promise((resolve) => setTimeout(resolve, 500)); 51 + return devServer.getLogs(200); 52 + } 53 + 54 + await new Promise((resolve) => setTimeout(resolve, 100)); 55 + } 56 + 57 + // On timeout, log what we DID see for debugging 58 + console.log("TIMEOUT - logs seen:", devServer.getLogs(50)); 59 + throw new Error(`Build did not complete within ${timeoutMs}ms`); 60 + } 61 + 62 + /** 63 + * Extract the build ID from an HTML file. 64 + */ 65 + function getBuildId(htmlPath: string): string | null { 66 + try { 67 + const content = readFileSync(htmlPath, "utf-8"); 68 + const match = content.match(/data-build-id="(\d+)"/); 69 + return match ? match[1] : null; 70 + } catch { 71 + return null; 72 + } 73 + } 74 + 75 + /** 76 + * Check if logs indicate incremental build was used 77 + */ 78 + function isIncrementalBuild(logs: string[]): boolean { 79 + return logs.join("\n").toLowerCase().includes("incremental build"); 80 + } 81 + 82 + /** 83 + * Get the number of affected routes from logs 84 + */ 85 + function getAffectedRouteCount(logs: string[]): number { 86 + const logsText = logs.join("\n"); 87 + const match = logsText.match(/Rebuilding (\d+) affected routes/i); 88 + return match ? parseInt(match[1], 10) : -1; 89 + } 90 + 91 + /** 92 + * Helper to set up incremental build state 93 + */ 94 + async function setupIncrementalState( 95 + devServer: any, 96 + triggerChange: (suffix: string) => Promise<string[]>, 97 + ): Promise<void> { 98 + // First change triggers a full build (no previous state) 99 + await triggerChange("init"); 100 + await new Promise((resolve) => setTimeout(resolve, 500)); 101 + 102 + // Second change should be incremental (state now exists) 103 + const logs = await triggerChange("setup"); 104 + expect(isIncrementalBuild(logs)).toBe(true); 105 + await new Promise((resolve) => setTimeout(resolve, 500)); 106 + } 107 + 108 + /** 109 + * Record build IDs for all pages 110 + */ 111 + function recordBuildIds(htmlPaths: Record<string, string>): Record<string, string | null> { 112 + const ids: Record<string, string | null> = {}; 113 + for (const [name, path] of Object.entries(htmlPaths)) { 114 + ids[name] = getBuildId(path); 115 + } 116 + return ids; 117 + } 118 + 119 + test.describe("Incremental Build", () => { 120 + test.setTimeout(180000); 121 + 122 + const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build"); 123 + 124 + // Asset paths 125 + const assets = { 126 + blogCss: resolve(fixturePath, "src", "assets", "blog.css"), 127 + utilsJs: resolve(fixturePath, "src", "assets", "utils.js"), 128 + mainJs: resolve(fixturePath, "src", "assets", "main.js"), 129 + aboutJs: resolve(fixturePath, "src", "assets", "about.js"), 130 + stylesCss: resolve(fixturePath, "src", "assets", "styles.css"), 131 + logoPng: resolve(fixturePath, "src", "assets", "logo.png"), 132 + teamPng: resolve(fixturePath, "src", "assets", "team.png"), 133 + bgPng: resolve(fixturePath, "src", "assets", "bg.png"), 134 + }; 135 + 136 + // Output HTML paths 137 + const htmlPaths = { 138 + index: resolve(fixturePath, "dist", "index.html"), 139 + about: resolve(fixturePath, "dist", "about", "index.html"), 140 + blog: resolve(fixturePath, "dist", "blog", "index.html"), 141 + }; 142 + 143 + // Original content storage 144 + const originals: Record<string, string | Buffer> = {}; 145 + 146 + test.beforeAll(async () => { 147 + // Store original content for all assets we might modify 148 + originals.blogCss = readFileSync(assets.blogCss, "utf-8"); 149 + originals.utilsJs = readFileSync(assets.utilsJs, "utf-8"); 150 + originals.mainJs = readFileSync(assets.mainJs, "utf-8"); 151 + originals.aboutJs = readFileSync(assets.aboutJs, "utf-8"); 152 + originals.stylesCss = readFileSync(assets.stylesCss, "utf-8"); 153 + originals.logoPng = readFileSync(assets.logoPng); // binary 154 + originals.teamPng = readFileSync(assets.teamPng); // binary 155 + originals.bgPng = readFileSync(assets.bgPng); // binary 156 + }); 157 + 158 + test.afterAll(async () => { 159 + // Restore all original content 160 + writeFileSync(assets.blogCss, originals.blogCss); 161 + writeFileSync(assets.utilsJs, originals.utilsJs); 162 + writeFileSync(assets.mainJs, originals.mainJs); 163 + writeFileSync(assets.aboutJs, originals.aboutJs); 164 + writeFileSync(assets.stylesCss, originals.stylesCss); 165 + writeFileSync(assets.logoPng, originals.logoPng); 166 + writeFileSync(assets.teamPng, originals.teamPng); 167 + writeFileSync(assets.bgPng, originals.bgPng); 168 + }); 169 + 170 + // ============================================================ 171 + // TEST 1: Direct CSS dependency (blog.css โ†’ /blog only) 172 + // ============================================================ 173 + test("CSS file change rebuilds only routes using it", async ({ devServer }) => { 174 + let testCounter = 0; 175 + 176 + async function triggerChange(suffix: string) { 177 + testCounter++; 178 + devServer.clearLogs(); 179 + writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 180 + return await waitForBuildComplete(devServer, 30000); 181 + } 182 + 183 + await setupIncrementalState(devServer, triggerChange); 184 + 185 + // Record build IDs before 186 + const before = recordBuildIds(htmlPaths); 187 + expect(before.index).not.toBeNull(); 188 + expect(before.about).not.toBeNull(); 189 + expect(before.blog).not.toBeNull(); 190 + 191 + await new Promise((resolve) => setTimeout(resolve, 500)); 192 + 193 + // Trigger the change 194 + const logs = await triggerChange("final"); 195 + 196 + // Verify incremental build with 1 route 197 + expect(isIncrementalBuild(logs)).toBe(true); 198 + expect(getAffectedRouteCount(logs)).toBe(1); 199 + 200 + // Verify only blog was rebuilt 201 + const after = recordBuildIds(htmlPaths); 202 + expect(after.index).toBe(before.index); 203 + expect(after.about).toBe(before.about); 204 + expect(after.blog).not.toBe(before.blog); 205 + }); 206 + 207 + // ============================================================ 208 + // TEST 2: Transitive JS dependency (utils.js โ†’ main.js โ†’ /) 209 + // ============================================================ 210 + test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => { 211 + let testCounter = 0; 212 + 213 + async function triggerChange(suffix: string) { 214 + testCounter++; 215 + devServer.clearLogs(); 216 + writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`); 217 + return await waitForBuildComplete(devServer, 30000); 218 + } 219 + 220 + await setupIncrementalState(devServer, triggerChange); 221 + 222 + const before = recordBuildIds(htmlPaths); 223 + expect(before.index).not.toBeNull(); 224 + 225 + await new Promise((resolve) => setTimeout(resolve, 500)); 226 + 227 + const logs = await triggerChange("final"); 228 + 229 + // Verify incremental build with 1 route 230 + expect(isIncrementalBuild(logs)).toBe(true); 231 + expect(getAffectedRouteCount(logs)).toBe(1); 232 + 233 + // Only index should be rebuilt (uses main.js which imports utils.js) 234 + const after = recordBuildIds(htmlPaths); 235 + expect(after.about).toBe(before.about); 236 + expect(after.blog).toBe(before.blog); 237 + expect(after.index).not.toBe(before.index); 238 + }); 239 + 240 + // ============================================================ 241 + // TEST 3: Direct JS entry point change (about.js โ†’ /about) 242 + // ============================================================ 243 + test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => { 244 + let testCounter = 0; 245 + 246 + async function triggerChange(suffix: string) { 247 + testCounter++; 248 + devServer.clearLogs(); 249 + writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 250 + return await waitForBuildComplete(devServer, 30000); 251 + } 252 + 253 + await setupIncrementalState(devServer, triggerChange); 254 + 255 + const before = recordBuildIds(htmlPaths); 256 + expect(before.about).not.toBeNull(); 257 + 258 + await new Promise((resolve) => setTimeout(resolve, 500)); 259 + 260 + const logs = await triggerChange("final"); 261 + 262 + // Verify incremental build with 1 route 263 + expect(isIncrementalBuild(logs)).toBe(true); 264 + expect(getAffectedRouteCount(logs)).toBe(1); 265 + 266 + // Only about should be rebuilt 267 + const after = recordBuildIds(htmlPaths); 268 + expect(after.index).toBe(before.index); 269 + expect(after.blog).toBe(before.blog); 270 + expect(after.about).not.toBe(before.about); 271 + }); 272 + 273 + // ============================================================ 274 + // TEST 4: Shared asset change (styles.css โ†’ / AND /about) 275 + // ============================================================ 276 + test("shared asset change rebuilds all routes using it", async ({ devServer }) => { 277 + let testCounter = 0; 278 + 279 + async function triggerChange(suffix: string) { 280 + testCounter++; 281 + devServer.clearLogs(); 282 + writeFileSync( 283 + assets.stylesCss, 284 + originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`, 285 + ); 286 + return await waitForBuildComplete(devServer, 30000); 287 + } 288 + 289 + await setupIncrementalState(devServer, triggerChange); 290 + 291 + const before = recordBuildIds(htmlPaths); 292 + expect(before.index).not.toBeNull(); 293 + expect(before.about).not.toBeNull(); 294 + 295 + await new Promise((resolve) => setTimeout(resolve, 500)); 296 + 297 + const logs = await triggerChange("final"); 298 + 299 + // Verify incremental build with 2 routes (/ and /about both use styles.css) 300 + expect(isIncrementalBuild(logs)).toBe(true); 301 + expect(getAffectedRouteCount(logs)).toBe(2); 302 + 303 + // Index and about should be rebuilt, blog should not 304 + const after = recordBuildIds(htmlPaths); 305 + expect(after.blog).toBe(before.blog); 306 + expect(after.index).not.toBe(before.index); 307 + expect(after.about).not.toBe(before.about); 308 + }); 309 + 310 + // ============================================================ 311 + // TEST 5: Image change (logo.png โ†’ /) 312 + // ============================================================ 313 + test("image change rebuilds only routes using it", async ({ devServer }) => { 314 + let testCounter = 0; 315 + 316 + async function triggerChange(suffix: string) { 317 + testCounter++; 318 + devServer.clearLogs(); 319 + // For images, we append bytes to change the file 320 + // This simulates modifying an image file 321 + const modified = Buffer.concat([ 322 + originals.logoPng as Buffer, 323 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 324 + ]); 325 + writeFileSync(assets.logoPng, modified); 326 + return await waitForBuildComplete(devServer, 30000); 327 + } 328 + 329 + await setupIncrementalState(devServer, triggerChange); 330 + 331 + const before = recordBuildIds(htmlPaths); 332 + expect(before.index).not.toBeNull(); 333 + 334 + await new Promise((resolve) => setTimeout(resolve, 500)); 335 + 336 + const logs = await triggerChange("final"); 337 + 338 + // Verify incremental build with 1 route 339 + expect(isIncrementalBuild(logs)).toBe(true); 340 + expect(getAffectedRouteCount(logs)).toBe(1); 341 + 342 + // Only index should be rebuilt (uses logo.png) 343 + const after = recordBuildIds(htmlPaths); 344 + expect(after.about).toBe(before.about); 345 + expect(after.blog).toBe(before.blog); 346 + expect(after.index).not.toBe(before.index); 347 + }); 348 + 349 + // ============================================================ 350 + // TEST 6: Multiple files changed simultaneously 351 + // ============================================================ 352 + test("multiple file changes rebuild union of affected routes", async ({ devServer }) => { 353 + let testCounter = 0; 354 + 355 + async function triggerChange(suffix: string) { 356 + testCounter++; 357 + devServer.clearLogs(); 358 + // Change both blog.css (affects /blog) and about.js (affects /about) 359 + writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 360 + writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 361 + return await waitForBuildComplete(devServer, 30000); 362 + } 363 + 364 + await setupIncrementalState(devServer, triggerChange); 365 + 366 + const before = recordBuildIds(htmlPaths); 367 + expect(before.about).not.toBeNull(); 368 + expect(before.blog).not.toBeNull(); 369 + 370 + await new Promise((resolve) => setTimeout(resolve, 500)); 371 + 372 + const logs = await triggerChange("final"); 373 + 374 + // Verify incremental build with 2 routes (/about and /blog) 375 + expect(isIncrementalBuild(logs)).toBe(true); 376 + expect(getAffectedRouteCount(logs)).toBe(2); 377 + 378 + // About and blog should be rebuilt, index should not 379 + const after = recordBuildIds(htmlPaths); 380 + expect(after.index).toBe(before.index); 381 + expect(after.about).not.toBe(before.about); 382 + expect(after.blog).not.toBe(before.blog); 383 + }); 384 + 385 + // ============================================================ 386 + // TEST 7: CSS url() asset dependency (bg.png via blog.css โ†’ /blog) 387 + // ============================================================ 388 + test("CSS url() asset change triggers rebundling and rebuilds affected routes", async ({ 389 + devServer, 390 + }) => { 391 + let testCounter = 0; 392 + 393 + async function triggerChange(suffix: string) { 394 + testCounter++; 395 + devServer.clearLogs(); 396 + // Modify bg.png - this is referenced via url() in blog.css 397 + // Changing it should trigger rebundling and rebuild /blog 398 + const modified = Buffer.concat([ 399 + originals.bgPng as Buffer, 400 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 401 + ]); 402 + writeFileSync(assets.bgPng, modified); 403 + return await waitForBuildComplete(devServer, 30000); 404 + } 405 + 406 + await setupIncrementalState(devServer, triggerChange); 407 + 408 + const before = recordBuildIds(htmlPaths); 409 + expect(before.blog).not.toBeNull(); 410 + 411 + await new Promise((resolve) => setTimeout(resolve, 500)); 412 + 413 + const logs = await triggerChange("final"); 414 + 415 + // Verify incremental build triggered 416 + expect(isIncrementalBuild(logs)).toBe(true); 417 + 418 + // Blog should be rebuilt (uses blog.css which references bg.png via url()) 419 + // The bundler should have been re-run to update the hashed asset reference 420 + const after = recordBuildIds(htmlPaths); 421 + expect(after.blog).not.toBe(before.blog); 422 + }); 423 + 424 + // ============================================================ 425 + // TEST 8: Folder rename detection 426 + // ============================================================ 427 + test("folder rename is detected and affects routes using assets in that folder", async ({ devServer }) => { 428 + // This test verifies that renaming a folder containing tracked assets 429 + // is detected by the file watcher and affects the correct routes. 430 + // 431 + // Setup: The blog page uses src/assets/icons/blog-icon.css 432 + // Test: Rename icons -> icons-renamed, verify the blog route is identified as affected 433 + // 434 + // Note: The actual build will fail because the asset path becomes invalid, 435 + // but this test verifies the DETECTION and ROUTE MATCHING works correctly. 436 + 437 + const iconsFolder = resolve(fixturePath, "src", "assets", "icons"); 438 + const renamedFolder = resolve(fixturePath, "src", "assets", "icons-renamed"); 439 + const iconFile = resolve(iconsFolder, "blog-icon.css"); 440 + 441 + // Ensure we start with the correct state 442 + if (existsSync(renamedFolder)) { 443 + // Restore from previous failed run 444 + renameSync(renamedFolder, iconsFolder); 445 + await new Promise((resolve) => setTimeout(resolve, 1000)); 446 + } 447 + 448 + // Make sure the icons folder exists with the file 449 + expect(existsSync(iconsFolder)).toBe(true); 450 + expect(existsSync(iconFile)).toBe(true); 451 + 452 + try { 453 + // First, trigger TWO builds to establish the asset tracking 454 + // The first build creates the state, the second ensures the icon is tracked 455 + const originalContent = readFileSync(iconFile, "utf-8"); 456 + 457 + // Build 1: Ensure blog-icon.css is used and tracked 458 + devServer.clearLogs(); 459 + writeFileSync(iconFile, originalContent + "\n/* setup1 */"); 460 + await waitForBuildComplete(devServer, 30000); 461 + await new Promise((resolve) => setTimeout(resolve, 500)); 462 + 463 + // Build 2: Now the asset should definitely be in the state 464 + devServer.clearLogs(); 465 + writeFileSync(iconFile, originalContent + "\n/* setup2 */"); 466 + await waitForBuildComplete(devServer, 30000); 467 + await new Promise((resolve) => setTimeout(resolve, 500)); 468 + 469 + // Clear for the actual test 470 + devServer.clearLogs(); 471 + 472 + // Rename icons -> icons-renamed 473 + renameSync(iconsFolder, renamedFolder); 474 + 475 + // Wait for the build to be attempted (it will fail because path is now invalid) 476 + const startTime = Date.now(); 477 + const timeoutMs = 15000; 478 + let logs: string[] = []; 479 + 480 + while (Date.now() - startTime < timeoutMs) { 481 + logs = devServer.getLogs(100); 482 + const logsText = logs.join("\n"); 483 + 484 + // Wait for either success or failure 485 + if (logsText.includes("finished") || logsText.includes("failed")) { 486 + break; 487 + } 488 + 489 + await new Promise((resolve) => setTimeout(resolve, 100)); 490 + } 491 + 492 + console.log("Logs after folder rename:", logs.slice(-15)); 493 + 494 + const logsText = logs.join("\n"); 495 + 496 + // Key assertions: verify the detection and route matching worked 497 + // 1. The folder paths should be in changed files 498 + expect(logsText).toContain("icons"); 499 + 500 + // 2. The blog route should be identified as affected 501 + expect(logsText).toContain("Rebuilding 1 affected routes"); 502 + expect(logsText).toContain("/blog"); 503 + 504 + // 3. Other routes should NOT be affected (index and about don't use icons/) 505 + expect(logsText).not.toContain("/about"); 506 + 507 + } finally { 508 + // Restore: rename icons-renamed back to icons 509 + if (existsSync(renamedFolder) && !existsSync(iconsFolder)) { 510 + renameSync(renamedFolder, iconsFolder); 511 + } 512 + // Restore original content 513 + if (existsSync(iconFile)) { 514 + const content = readFileSync(iconFile, "utf-8"); 515 + writeFileSync(iconFile, content.replace(/\n\/\* setup[12] \*\//g, "")); 516 + } 517 + // Wait for restoration to be processed 518 + await new Promise((resolve) => setTimeout(resolve, 1000)); 519 + } 520 + }); 521 + });
+76 -6
e2e/tests/test-utils.ts
··· 23 port: number; 24 /** Stop the dev server */ 25 stop: () => Promise<void>; 26 } 27 28 /** ··· 52 const childProcess = spawn(command, args, { 53 cwd: fixturePath, 54 stdio: ["ignore", "pipe", "pipe"], 55 }); 56 57 // Capture output to detect when server is ready 58 let serverReady = false; 59 60 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); 64 65 childProcess.stdout?.on("data", (data: Buffer) => { 66 const output = data.toString(); 67 68 // Look for "waiting for requests" to know server is ready 69 if (output.includes("waiting for requests")) { ··· 75 }); 76 77 childProcess.stderr?.on("data", (data: Buffer) => { 78 - // Only log errors, not all stderr output 79 const output = data.toString(); 80 if (output.toLowerCase().includes("error")) { 81 console.error(`[maudit dev] ${output}`); 82 } ··· 113 }, 5000); 114 }); 115 }, 116 }; 117 } 118 ··· 138 // Worker-scoped server pool - one server per worker, shared across all tests in that worker 139 // Key format: "workerIndex-fixtureName" 140 const workerServers = new Map<string, DevServer>(); 141 142 /** 143 * Create a test instance with a devServer fixture for a specific fixture. 144 * This allows each test file to use a different fixture while sharing the same pattern. 145 * 146 * @param fixtureName - Name of the fixture directory under e2e/fixtures/ 147 - * @param basePort - Starting port number (default: 1864). Each worker gets basePort + workerIndex 148 * 149 * @example 150 * ```ts ··· 167 let server = workerServers.get(serverKey); 168 169 if (!server) { 170 - // Assign unique port based on worker index 171 - const port = basePort + workerIndex; 172 173 server = await startDevServer({ 174 fixture: fixtureName,
··· 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 } 31 32 /** ··· 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 }); 65 66 // Capture output to detect when server is ready 67 let serverReady = false; 68 + const capturedLogs: string[] = []; 69 70 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 76 77 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 + }); 86 87 // Look for "waiting for requests" to know server is ready 88 if (output.includes("waiting for requests")) { ··· 94 }); 95 96 childProcess.stderr?.on("data", (data: Buffer) => { 97 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 ··· 174 // 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 + } 209 210 /** 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 ··· 235 let server = workerServers.get(serverKey); 236 237 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); 242 243 server = await startDevServer({ 244 fixture: fixtureName,
+1 -1
website/content/docs/prefetching.md
··· 49 50 Note 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. 51 52 - ## Possible risks 53 54 Prefetching 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
··· 49 50 Note 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. 51 52 + ## Possible risks 53 54 Prefetching 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
+1 -1
website/content/news/2026-in-the-cursed-lands.md
··· 70 71 ### Shortcodes 72 73 - 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. 74 75 ```md 76 Here's my cool video:
··· 70 71 ### Shortcodes 72 73 + 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. 74 75 ```md 76 Here's my cool video:

History

1 round 0 comments
sign up or login to add to the discussion
erika.florist submitted #0
20 commits
expand
feat: smoother hot reload
fix: fix
fix: e2e tests
fix: longer timeout perhaps
fix: make it more reliable
fix: don't recompute dirs
fix: use same target finding function in assets
feat: some sort of incremental builds
fix: some things
fix: lint
fix: remove unrelated changes
fix: update hot reload tests
fix: just some clean up
fix: transitive dependencies
fix: bunch of fixes
fix: allow showing the binary output in dev if needs be
perf: cache is_dev()
fix: no idea what im doing
fix: folders
fix: folders
merge conflicts detected
expand
  • Cargo.lock:1661
  • Cargo.lock:2622
  • Cargo.lock:1661
  • crates/maudit-cli/src/dev.rs:21
  • e2e/fixtures/hot-reload/src/main.rs:1
  • e2e/fixtures/prefetch-prerender/src/main.rs:1
  • crates/maudit-cli/src/dev.rs:10
  • .github/workflows/benchmark.yaml:41
  • .github/workflows/ci.yaml:38
  • .github/workflows/release.yml:30
  • .vscode/extensions.json:1
  • .vscode/settings.json:1
  • crates/maudit-cli/src/dev.rs:15
  • crates/maudit/Cargo.toml:50
  • e2e/README.md:13
  • website/content/docs/content.md:214
  • website/content/docs/prefetching.md:49
  • website/content/news/2026-in-the-cursed-lands.md:70
  • crates/maudit-cli/src/dev.rs:172
  • website/content/news/2026-in-the-cursed-lands.md:70
expand 0 comments