Rust library to generate static websites

fix: folders

+178 -89
-38
crates/maudit-cli/src/dev.rs
··· 172 172 .flat_map(|e| e.paths.iter().cloned()) 173 173 .collect(); 174 174 175 - // Expand directory paths to include files inside them 176 - // This is needed because folder renames only report the folder, not contents 177 - changed_paths = expand_directory_paths(changed_paths); 178 - 179 175 // Deduplicate paths 180 176 changed_paths.sort(); 181 177 changed_paths.dedup(); ··· 299 295 } 300 296 301 297 true 302 - } 303 - 304 - /// Expand directory paths to include all files within them recursively. 305 - /// This is needed because file watcher events for folder renames only include 306 - /// the folder path, not the files inside. 307 - fn expand_directory_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> { 308 - let mut expanded = Vec::new(); 309 - 310 - for path in paths { 311 - if path.is_dir() { 312 - // Recursively collect all files in the directory 313 - collect_files_recursive(&path, &mut expanded); 314 - // Also keep the directory itself for cases where we need to know a dir changed 315 - expanded.push(path); 316 - } else { 317 - expanded.push(path); 318 - } 319 - } 320 - 321 - expanded 322 - } 323 - 324 - /// Recursively collect all files in a directory. 325 - fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) { 326 - if let Ok(entries) = fs::read_dir(dir) { 327 - for entry in entries.filter_map(|e| e.ok()) { 328 - let path = entry.path(); 329 - if path.is_dir() { 330 - collect_files_recursive(&path, files); 331 - } else if path.is_file() { 332 - files.push(path); 333 - } 334 - } 335 - } 336 298 } 337 299 338 300 async fn shutdown_signal() {
+2 -2
crates/maudit-cli/src/dev/build.rs
··· 100 100 }; 101 101 102 102 // Log that we're doing an incremental build 103 - info!(name: "build", "Incremental build: {} files changed", changed_paths.len()); 103 + debug!(name: "build", "Incremental build: {} files changed", changed_paths.len()); 104 104 debug!(name: "build", "Changed files: {:?}", changed_paths); 105 - info!(name: "build", "Rerunning binary without recompilation..."); 105 + debug!(name: "build", "Rerunning binary without recompilation..."); 106 106 107 107 self.state 108 108 .status_manager
+94 -6
crates/maudit/src/build/state.rs
··· 121 121 continue; 122 122 } 123 123 124 - // Only do directory prefix check if the changed path is actually a directory 125 - // This handles cases where a directory is modified and we want to rebuild 126 - // all routes that use assets within that directory 127 - if changed_file.is_dir() { 128 - let canonical_dir = canonical_changed.as_ref().unwrap_or(changed_file); 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) 129 137 for (asset_path, routes) in &self.asset_to_routes { 130 - if asset_path.starts_with(canonical_dir) { 138 + if asset_path.starts_with(changed_file) { 131 139 affected_routes.extend(routes.iter().cloned()); 132 140 } 133 141 } ··· 143 151 self.bundler_inputs.clear(); 144 152 } 145 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 + }
+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 */
+1
e2e/fixtures/incremental-build/src/pages/blog.rs
··· 8 8 impl Route for Blog { 9 9 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 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"); 11 12 12 13 // Generate a unique build ID - uses nanoseconds for uniqueness 13 14 let build_id = SystemTime::now()
+70 -40
e2e/tests/incremental-build.spec.ts
··· 1 1 import { expect } from "@playwright/test"; 2 2 import { createTestWithFixture } from "./test-utils"; 3 - import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, existsSync } from "node:fs"; 3 + import { readFileSync, writeFileSync, renameSync, rmSync, existsSync } from "node:fs"; 4 4 import { resolve, dirname } from "node:path"; 5 5 import { fileURLToPath } from "node:url"; 6 6 ··· 54 54 await new Promise((resolve) => setTimeout(resolve, 100)); 55 55 } 56 56 57 + // On timeout, log what we DID see for debugging 58 + console.log("TIMEOUT - logs seen:", devServer.getLogs(50)); 57 59 throw new Error(`Build did not complete within ${timeoutMs}ms`); 58 60 } 59 61 ··· 420 422 }); 421 423 422 424 // ============================================================ 423 - // TEST 8: Folder rename handling 425 + // TEST 8: Folder rename detection 424 426 // ============================================================ 425 - test("folder rename triggers rebuild with correct changed paths", async ({ devServer }) => { 426 - // Create a test folder structure that we can rename 427 - const testFolder = resolve(fixturePath, "src", "assets", "test-icons"); 428 - const renamedFolder = resolve(fixturePath, "src", "assets", "test-icons-renamed"); 429 - const testFile = resolve(testFolder, "test-icon.css"); 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"); 430 440 431 - // Clean up any leftover folders from previous test runs 432 - if (existsSync(testFolder)) { 433 - rmSync(testFolder, { recursive: true }); 434 - } 441 + // Ensure we start with the correct state 435 442 if (existsSync(renamedFolder)) { 436 - rmSync(renamedFolder, { recursive: true }); 443 + // Restore from previous failed run 444 + renameSync(renamedFolder, iconsFolder); 445 + await new Promise((resolve) => setTimeout(resolve, 1000)); 437 446 } 438 447 448 + // Make sure the icons folder exists with the file 449 + expect(existsSync(iconsFolder)).toBe(true); 450 + expect(existsSync(iconFile)).toBe(true); 451 + 439 452 try { 440 - // Step 1: Create the test folder and file 441 - mkdirSync(testFolder, { recursive: true }); 442 - writeFileSync(testFile, "/* test icon styles */\n.icon { width: 16px; }"); 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)); 443 462 444 - // Wait for initial detection 463 + // Build 2: Now the asset should definitely be in the state 445 464 devServer.clearLogs(); 446 - await new Promise((resolve) => setTimeout(resolve, 1000)); 465 + writeFileSync(iconFile, originalContent + "\n/* setup2 */"); 466 + await waitForBuildComplete(devServer, 30000); 467 + await new Promise((resolve) => setTimeout(resolve, 500)); 447 468 448 - // Step 2: Rename the folder 469 + // Clear for the actual test 449 470 devServer.clearLogs(); 450 - renameSync(testFolder, renamedFolder); 471 + 472 + // Rename icons -> icons-renamed 473 + renameSync(iconsFolder, renamedFolder); 451 474 452 - // Wait for the file watcher to detect the rename and process it 475 + // Wait for the build to be attempted (it will fail because path is now invalid) 453 476 const startTime = Date.now(); 454 - const timeoutMs = 10000; 455 - let logsAfterRename: string[] = []; 477 + const timeoutMs = 15000; 478 + let logs: string[] = []; 456 479 457 480 while (Date.now() - startTime < timeoutMs) { 458 - logsAfterRename = devServer.getLogs(100); 459 - const logsText = logsAfterRename.join("\n"); 481 + logs = devServer.getLogs(100); 482 + const logsText = logs.join("\n"); 460 483 461 - // Wait for the build to complete (indicates paths were processed) 462 - if (logsText.includes("rerun finished") || logsText.includes("Build completed")) { 484 + // Wait for either success or failure 485 + if (logsText.includes("finished") || logsText.includes("failed")) { 463 486 break; 464 487 } 465 488 466 489 await new Promise((resolve) => setTimeout(resolve, 100)); 467 490 } 468 491 469 - // Log what we received for debugging 470 - console.log("Logs after folder rename:", logsAfterRename.slice(-20)); 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"); 471 499 472 - const logsText = logsAfterRename.join("\n"); 500 + // 2. The blog route should be identified as affected 501 + expect(logsText).toContain("Rebuilding 1 affected routes"); 502 + expect(logsText).toContain("/blog"); 473 503 474 - // Verify that events were received 475 - expect(logsText).toContain("Received"); 504 + // 3. Other routes should NOT be affected (index and about don't use icons/) 505 + expect(logsText).not.toContain("/about"); 476 506 477 - // The key assertion: the changed files should include the FILE inside the folder, 478 - // not just the folder path itself 479 - // After expanding directory paths, we should see the CSS file 480 - expect(logsText).toContain("test-icon.css"); 481 507 } finally { 482 - // Cleanup: remove test folders 483 - if (existsSync(testFolder)) { 484 - rmSync(testFolder, { recursive: true }); 508 + // Restore: rename icons-renamed back to icons 509 + if (existsSync(renamedFolder) && !existsSync(iconsFolder)) { 510 + renameSync(renamedFolder, iconsFolder); 485 511 } 486 - if (existsSync(renamedFolder)) { 487 - rmSync(renamedFolder, { recursive: true }); 512 + // Restore original content 513 + if (existsSync(iconFile)) { 514 + const content = readFileSync(iconFile, "utf-8"); 515 + writeFileSync(iconFile, content.replace(/\n\/\* setup[12] \*\//g, "")); 488 516 } 517 + // Wait for restoration to be processed 518 + await new Promise((resolve) => setTimeout(resolve, 1000)); 489 519 } 490 520 }); 491 521 });
+1 -1
examples/basics/src/routes/index.rs
··· 11 11 12 12 Ok(layout(html! { 13 13 (logo.render("Maudit logo, a crudely drawn crown")) 14 - h1 { "Hello World 3" } 14 + h1 { "Hello World" } 15 15 })) 16 16 } 17 17 }
+1 -1
website/content/docs/content.md
··· 214 214 215 215 ```markdown 216 216 --- 217 - title: { { enhance title="Super Title" / } } 217 + title: {{ enhance title="Super Title" /}} 218 218 --- 219 219 220 220 Here's an image with a caption:
+1 -1
website/content/news/2026-in-the-cursed-lands.md
··· 70 70 71 71 ### Shortcodes 72 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. 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 74 75 75 ```md 76 76 Here's my cool video: