Rust library to generate static websites

fix: transitive dependencies

+360 -58
+1
Cargo.lock
··· 2582 2582 dependencies = [ 2583 2583 "base64", 2584 2584 "brk_rolldown", 2585 + "brk_rolldown_common", 2585 2586 "brk_rolldown_plugin_replace", 2586 2587 "chrono", 2587 2588 "colored 3.1.1",
+1
crates/maudit/Cargo.toml
··· 23 23 24 24 # TODO: Allow making those optional 25 25 rolldown = { package = "brk_rolldown", version = "0.8.0" } 26 + rolldown_common = { package = "brk_rolldown_common", version = "0.8.0" } 26 27 serde = { workspace = true } 27 28 serde_json = "1.0" 28 29 serde_yaml = "0.9.34"
+1 -2
crates/maudit/src/assets/image_cache.rs
··· 347 347 ..Default::default() 348 348 }; 349 349 350 - // Create cache with build options 351 - let cache = ImageCache::with_cache_dir(&build_options.assets_cache_dir()); 350 + let cache = ImageCache::with_cache_dir(build_options.assets_cache_dir()); 352 351 353 352 // Verify it uses the configured directory 354 353 assert_eq!(cache.get_cache_dir(), custom_cache.join("assets"));
+69 -10
crates/maudit/src/build.rs
··· 26 26 use log::{debug, info, trace, warn}; 27 27 use pathdiff::diff_paths; 28 28 use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType}; 29 + use rolldown_common::Output; 29 30 use rolldown_plugin_replace::ReplacePlugin; 30 31 use rustc_hash::{FxHashMap, FxHashSet}; 31 32 ··· 118 119 BuildState::new() 119 120 }; 120 121 122 + debug!(target: "build", "Loaded build state with {} asset mappings", build_state.asset_to_routes.len()); 123 + debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some()); 124 + 121 125 // Determine if this is an incremental build 122 126 let is_incremental = options.incremental && changed_files.is_some() && !build_state.asset_to_routes.is_empty(); 123 127 ··· 143 147 }; 144 148 145 149 // Check if we should rebundle during incremental builds 146 - // Only rebundle if a changed file is in the bundler inputs 150 + // Rebundle if a changed file is either: 151 + // 1. A direct bundler input (entry point) 152 + // 2. A transitive dependency tracked in asset_to_routes (JS/CSS/TS files) 147 153 let should_rebundle = if is_incremental && !build_state.bundler_inputs.is_empty() { 148 154 let changed = changed_files.unwrap(); 149 155 let should = changed.iter().any(|changed_file| { 150 - build_state.bundler_inputs.iter().any(|bundler_input| { 151 - // Check if the changed file matches any bundler input 152 - // Canonicalize both paths for comparison 156 + // Check if it's a direct bundler input 157 + let is_bundler_input = build_state.bundler_inputs.iter().any(|bundler_input| { 153 158 if let (Ok(changed_canonical), Ok(bundler_canonical)) = ( 154 159 changed_file.canonicalize(), 155 160 PathBuf::from(bundler_input).canonicalize() ··· 158 163 } else { 159 164 false 160 165 } 161 - }) 166 + }); 167 + 168 + if is_bundler_input { 169 + return true; 170 + } 171 + 172 + // Check if it's a transitive dependency (JS/CSS/TS file in asset_to_routes) 173 + if let Some(ext) = changed_file.extension().and_then(|e| e.to_str()) { 174 + let is_bundleable = matches!(ext.to_lowercase().as_str(), "js" | "ts" | "jsx" | "tsx" | "css"); 175 + if is_bundleable 176 + && let Ok(canonical) = changed_file.canonicalize() { 177 + return build_state.asset_to_routes.contains_key(&canonical); 178 + } 179 + } 180 + 181 + false 162 182 }); 163 183 164 184 if should { 165 - info!(target: "build", "Rebundling needed: changed file matches bundler input"); 185 + info!(target: "build", "Rebundling needed: changed file affects bundled assets"); 166 186 } else { 167 - info!(target: "build", "Skipping bundler: no changed files match bundler inputs"); 187 + info!(target: "build", "Skipping bundler: no changed files affect bundled assets"); 168 188 } 169 189 170 190 should ··· 190 210 }; 191 211 192 212 // Create the image cache early so it can be shared across routes 193 - let image_cache = ImageCache::with_cache_dir(&options.assets_cache_dir()); 213 + let image_cache = ImageCache::with_cache_dir(options.assets_cache_dir()); 194 214 let _ = fs::create_dir_all(image_cache.get_cache_dir()); 195 215 196 216 // Create route_assets_options with the image cache ··· 702 722 ], 703 723 )?; 704 724 705 - let _result = bundler.write().await?; 725 + let result = bundler.write().await?; 706 726 707 - // TODO: Add outputted chunks to build_metadata 727 + // Track transitive dependencies from bundler output 728 + // For each chunk, map all its modules to the routes that use the entry point 729 + if options.incremental { 730 + for output in &result.assets { 731 + if let Output::Chunk(chunk) = output { 732 + // Get the entry point for this chunk 733 + if let Some(facade_module_id) = &chunk.facade_module_id { 734 + // Try to find routes using this entry point 735 + let entry_path = PathBuf::from(facade_module_id.as_str()); 736 + let canonical_entry = entry_path.canonicalize().ok(); 737 + 738 + // Look up routes for this entry point 739 + let routes = canonical_entry 740 + .as_ref() 741 + .and_then(|p| build_state.asset_to_routes.get(p)) 742 + .cloned(); 743 + 744 + if let Some(routes) = routes { 745 + // Register all modules in this chunk as dependencies for those routes 746 + let mut transitive_count = 0; 747 + for module_id in &chunk.module_ids { 748 + let module_path = PathBuf::from(module_id.as_str()); 749 + if let Ok(canonical_module) = module_path.canonicalize() { 750 + // Skip the entry point itself (already tracked) 751 + if Some(&canonical_module) != canonical_entry.as_ref() { 752 + for route in &routes { 753 + build_state.track_asset(canonical_module.clone(), route.clone()); 754 + } 755 + transitive_count += 1; 756 + } 757 + } 758 + } 759 + if transitive_count > 0 { 760 + debug!(target: "build", "Tracked {} transitive dependencies for {}", transitive_count, facade_module_id); 761 + } 762 + } 763 + } 764 + } 765 + } 766 + } 708 767 } 709 768 710 769 info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), &section_format_options)).bold());
+3
e2e/fixtures/incremental-build/src/assets/main.js
··· 1 1 // Main script 2 + import { greet } from './utils.js'; 3 + 2 4 console.log('Main script loaded'); 5 + console.log(greet('World'));
+4
e2e/fixtures/incremental-build/src/assets/utils.js
··· 1 + // Utility functions 2 + export function greet(name) { 3 + return `Hello, ${name}!`; 4 + }
+2
e2e/fixtures/incremental-build/src/pages/about.rs
··· 9 9 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 10 let _image = ctx.assets.add_image("src/assets/team.png"); 11 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"); 12 14 13 15 // Generate a unique build ID - uses nanoseconds for uniqueness 14 16 let build_id = SystemTime::now()
+242 -43
e2e/tests/incremental-build.spec.ts
··· 1 1 import { expect } from "@playwright/test"; 2 2 import { createTestWithFixture } from "./test-utils"; 3 - import { readFileSync, writeFileSync } from "node:fs"; 3 + import { readFileSync, writeFileSync, copyFileSync } from "node:fs"; 4 4 import { resolve, dirname } from "node:path"; 5 5 import { fileURLToPath } from "node:url"; 6 6 ··· 82 82 return match ? parseInt(match[1], 10) : -1; 83 83 } 84 84 85 + /** 86 + * Helper to set up incremental build state 87 + */ 88 + async function setupIncrementalState( 89 + devServer: any, 90 + triggerChange: (suffix: string) => Promise<string[]> 91 + ): Promise<void> { 92 + // First change triggers a full build (no previous state) 93 + await triggerChange("init"); 94 + await new Promise(resolve => setTimeout(resolve, 500)); 95 + 96 + // Second change should be incremental (state now exists) 97 + const logs = await triggerChange("setup"); 98 + expect(isIncrementalBuild(logs)).toBe(true); 99 + await new Promise(resolve => setTimeout(resolve, 500)); 100 + } 101 + 102 + /** 103 + * Record build IDs for all pages 104 + */ 105 + function recordBuildIds(htmlPaths: Record<string, string>): Record<string, string | null> { 106 + const ids: Record<string, string | null> = {}; 107 + for (const [name, path] of Object.entries(htmlPaths)) { 108 + ids[name] = getBuildId(path); 109 + } 110 + return ids; 111 + } 112 + 85 113 test.describe("Incremental Build", () => { 86 114 test.setTimeout(180000); 87 115 88 116 const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build"); 89 117 90 118 // Asset paths 91 - const blogStylesPath = resolve(fixturePath, "src", "assets", "blog.css"); 119 + const assets = { 120 + blogCss: resolve(fixturePath, "src", "assets", "blog.css"), 121 + utilsJs: resolve(fixturePath, "src", "assets", "utils.js"), 122 + mainJs: resolve(fixturePath, "src", "assets", "main.js"), 123 + aboutJs: resolve(fixturePath, "src", "assets", "about.js"), 124 + stylesCss: resolve(fixturePath, "src", "assets", "styles.css"), 125 + logoPng: resolve(fixturePath, "src", "assets", "logo.png"), 126 + teamPng: resolve(fixturePath, "src", "assets", "team.png"), 127 + }; 92 128 93 129 // Output HTML paths 94 130 const htmlPaths = { ··· 98 134 }; 99 135 100 136 // Original content storage 101 - let originalBlogStyles: string; 137 + const originals: Record<string, string | Buffer> = {}; 102 138 103 139 test.beforeAll(async () => { 104 - originalBlogStyles = readFileSync(blogStylesPath, "utf-8"); 105 - // Ensure file is in original state 106 - writeFileSync(blogStylesPath, originalBlogStyles, "utf-8"); 140 + // Store original content for all assets we might modify 141 + originals.blogCss = readFileSync(assets.blogCss, "utf-8"); 142 + originals.utilsJs = readFileSync(assets.utilsJs, "utf-8"); 143 + originals.mainJs = readFileSync(assets.mainJs, "utf-8"); 144 + originals.aboutJs = readFileSync(assets.aboutJs, "utf-8"); 145 + originals.stylesCss = readFileSync(assets.stylesCss, "utf-8"); 146 + originals.logoPng = readFileSync(assets.logoPng); // binary 147 + originals.teamPng = readFileSync(assets.teamPng); // binary 107 148 }); 108 149 109 150 test.afterAll(async () => { 110 - // Restore original content 111 - writeFileSync(blogStylesPath, originalBlogStyles, "utf-8"); 151 + // Restore all original content 152 + writeFileSync(assets.blogCss, originals.blogCss); 153 + writeFileSync(assets.utilsJs, originals.utilsJs); 154 + writeFileSync(assets.mainJs, originals.mainJs); 155 + writeFileSync(assets.aboutJs, originals.aboutJs); 156 + writeFileSync(assets.stylesCss, originals.stylesCss); 157 + writeFileSync(assets.logoPng, originals.logoPng); 158 + writeFileSync(assets.teamPng, originals.teamPng); 159 + }); 160 + 161 + // ============================================================ 162 + // TEST 1: Direct CSS dependency (blog.css → /blog only) 163 + // ============================================================ 164 + test("CSS file change rebuilds only routes using it", async ({ devServer }) => { 165 + let testCounter = 0; 166 + 167 + async function triggerChange(suffix: string) { 168 + testCounter++; 169 + devServer.clearLogs(); 170 + writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 171 + return await waitForBuildComplete(devServer, 30000); 172 + } 173 + 174 + await setupIncrementalState(devServer, triggerChange); 175 + 176 + // Record build IDs before 177 + const before = recordBuildIds(htmlPaths); 178 + expect(before.index).not.toBeNull(); 179 + expect(before.about).not.toBeNull(); 180 + expect(before.blog).not.toBeNull(); 181 + 182 + await new Promise(resolve => setTimeout(resolve, 500)); 183 + 184 + // Trigger the change 185 + const logs = await triggerChange("final"); 186 + 187 + // Verify incremental build with 1 route 188 + expect(isIncrementalBuild(logs)).toBe(true); 189 + expect(getAffectedRouteCount(logs)).toBe(1); 190 + 191 + // Verify only blog was rebuilt 192 + const after = recordBuildIds(htmlPaths); 193 + expect(after.index).toBe(before.index); 194 + expect(after.about).toBe(before.about); 195 + expect(after.blog).not.toBe(before.blog); 196 + }); 197 + 198 + // ============================================================ 199 + // TEST 2: Transitive JS dependency (utils.js → main.js → /) 200 + // ============================================================ 201 + test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => { 202 + let testCounter = 0; 203 + 204 + async function triggerChange(suffix: string) { 205 + testCounter++; 206 + devServer.clearLogs(); 207 + writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`); 208 + return await waitForBuildComplete(devServer, 30000); 209 + } 210 + 211 + await setupIncrementalState(devServer, triggerChange); 212 + 213 + const before = recordBuildIds(htmlPaths); 214 + expect(before.index).not.toBeNull(); 215 + 216 + await new Promise(resolve => setTimeout(resolve, 500)); 217 + 218 + const logs = await triggerChange("final"); 219 + 220 + // Verify incremental build with 1 route 221 + expect(isIncrementalBuild(logs)).toBe(true); 222 + expect(getAffectedRouteCount(logs)).toBe(1); 223 + 224 + // Only index should be rebuilt (uses main.js which imports utils.js) 225 + const after = recordBuildIds(htmlPaths); 226 + expect(after.about).toBe(before.about); 227 + expect(after.blog).toBe(before.blog); 228 + expect(after.index).not.toBe(before.index); 112 229 }); 113 230 114 - test("incremental builds only rebuild affected routes", async ({ devServer }) => { 231 + // ============================================================ 232 + // TEST 3: Direct JS entry point change (about.js → /about) 233 + // ============================================================ 234 + test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => { 115 235 let testCounter = 0; 116 236 117 237 async function triggerChange(suffix: string) { 118 238 testCounter++; 119 239 devServer.clearLogs(); 120 - writeFileSync(blogStylesPath, originalBlogStyles + `\n/* test-${testCounter}-${suffix} */`, "utf-8"); 240 + writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 121 241 return await waitForBuildComplete(devServer, 30000); 122 242 } 123 243 124 - // ======================================== 125 - // SETUP: Establish incremental build state 126 - // ======================================== 127 - // First change triggers a full build (no previous state) 128 - await triggerChange("init"); 244 + await setupIncrementalState(devServer, triggerChange); 245 + 246 + const before = recordBuildIds(htmlPaths); 247 + expect(before.about).not.toBeNull(); 248 + 129 249 await new Promise(resolve => setTimeout(resolve, 500)); 130 250 131 - // Second change should be incremental (state now exists) 132 - let logs = await triggerChange("setup"); 251 + const logs = await triggerChange("final"); 252 + 253 + // Verify incremental build with 1 route 133 254 expect(isIncrementalBuild(logs)).toBe(true); 255 + expect(getAffectedRouteCount(logs)).toBe(1); 256 + 257 + // Only about should be rebuilt 258 + const after = recordBuildIds(htmlPaths); 259 + expect(after.index).toBe(before.index); 260 + expect(after.blog).toBe(before.blog); 261 + expect(after.about).not.toBe(before.about); 262 + }); 263 + 264 + // ============================================================ 265 + // TEST 4: Shared asset change (styles.css → / AND /about) 266 + // ============================================================ 267 + test("shared asset change rebuilds all routes using it", async ({ devServer }) => { 268 + let testCounter = 0; 269 + 270 + async function triggerChange(suffix: string) { 271 + testCounter++; 272 + devServer.clearLogs(); 273 + writeFileSync(assets.stylesCss, originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`); 274 + return await waitForBuildComplete(devServer, 30000); 275 + } 276 + 277 + await setupIncrementalState(devServer, triggerChange); 278 + 279 + const before = recordBuildIds(htmlPaths); 280 + expect(before.index).not.toBeNull(); 281 + expect(before.about).not.toBeNull(); 282 + 134 283 await new Promise(resolve => setTimeout(resolve, 500)); 284 + 285 + const logs = await triggerChange("final"); 286 + 287 + // Verify incremental build with 2 routes (/ and /about both use styles.css) 288 + expect(isIncrementalBuild(logs)).toBe(true); 289 + expect(getAffectedRouteCount(logs)).toBe(2); 290 + 291 + // Index and about should be rebuilt, blog should not 292 + const after = recordBuildIds(htmlPaths); 293 + expect(after.blog).toBe(before.blog); 294 + expect(after.index).not.toBe(before.index); 295 + expect(after.about).not.toBe(before.about); 296 + }); 135 297 136 - // ======================================== 137 - // TEST: CSS file change (blog.css → only /blog) 138 - // ======================================== 139 - // Record build IDs before 140 - const beforeIndex = getBuildId(htmlPaths.index); 141 - const beforeAbout = getBuildId(htmlPaths.about); 142 - const beforeBlog = getBuildId(htmlPaths.blog); 298 + // ============================================================ 299 + // TEST 5: Image change (logo.png → /) 300 + // ============================================================ 301 + test("image change rebuilds only routes using it", async ({ devServer }) => { 302 + let testCounter = 0; 143 303 144 - expect(beforeIndex).not.toBeNull(); 145 - expect(beforeAbout).not.toBeNull(); 146 - expect(beforeBlog).not.toBeNull(); 304 + async function triggerChange(suffix: string) { 305 + testCounter++; 306 + devServer.clearLogs(); 307 + // For images, we append bytes to change the file 308 + // This simulates modifying an image file 309 + const modified = Buffer.concat([ 310 + originals.logoPng as Buffer, 311 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`) 312 + ]); 313 + writeFileSync(assets.logoPng, modified); 314 + return await waitForBuildComplete(devServer, 30000); 315 + } 147 316 148 - // Wait a bit more to ensure clean slate 317 + await setupIncrementalState(devServer, triggerChange); 318 + 319 + const before = recordBuildIds(htmlPaths); 320 + expect(before.index).not.toBeNull(); 321 + 149 322 await new Promise(resolve => setTimeout(resolve, 500)); 150 323 151 - // Trigger the change 152 - logs = await triggerChange("final"); 324 + const logs = await triggerChange("final"); 153 325 154 - // Verify it was an incremental build 326 + // Verify incremental build with 1 route 155 327 expect(isIncrementalBuild(logs)).toBe(true); 328 + expect(getAffectedRouteCount(logs)).toBe(1); 156 329 157 - // Verify exactly 1 route was rebuilt (from logs) 158 - const routeCount = getAffectedRouteCount(logs); 159 - expect(routeCount).toBe(1); 330 + // Only index should be rebuilt (uses logo.png) 331 + const after = recordBuildIds(htmlPaths); 332 + expect(after.about).toBe(before.about); 333 + expect(after.blog).toBe(before.blog); 334 + expect(after.index).not.toBe(before.index); 335 + }); 336 + 337 + // ============================================================ 338 + // TEST 6: Multiple files changed simultaneously 339 + // ============================================================ 340 + test("multiple file changes rebuild union of affected routes", async ({ devServer }) => { 341 + let testCounter = 0; 342 + 343 + async function triggerChange(suffix: string) { 344 + testCounter++; 345 + devServer.clearLogs(); 346 + // Change both blog.css (affects /blog) and about.js (affects /about) 347 + writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 348 + writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 349 + return await waitForBuildComplete(devServer, 30000); 350 + } 160 351 161 - // Verify build IDs: only blog should have changed 162 - const afterIndex = getBuildId(htmlPaths.index); 163 - const afterAbout = getBuildId(htmlPaths.about); 164 - const afterBlog = getBuildId(htmlPaths.blog); 352 + await setupIncrementalState(devServer, triggerChange); 353 + 354 + const before = recordBuildIds(htmlPaths); 355 + expect(before.about).not.toBeNull(); 356 + expect(before.blog).not.toBeNull(); 357 + 358 + await new Promise(resolve => setTimeout(resolve, 500)); 359 + 360 + const logs = await triggerChange("final"); 165 361 166 - // Index and about should NOT have been rebuilt 167 - expect(afterIndex).toBe(beforeIndex); 168 - expect(afterAbout).toBe(beforeAbout); 362 + // Verify incremental build with 2 routes (/about and /blog) 363 + expect(isIncrementalBuild(logs)).toBe(true); 364 + expect(getAffectedRouteCount(logs)).toBe(2); 169 365 170 - // Blog SHOULD have been rebuilt 171 - expect(afterBlog).not.toBe(beforeBlog); 366 + // About and blog should be rebuilt, index should not 367 + const after = recordBuildIds(htmlPaths); 368 + expect(after.index).toBe(before.index); 369 + expect(after.about).not.toBe(before.about); 370 + expect(after.blog).not.toBe(before.blog); 172 371 }); 173 372 });
+37 -3
e2e/tests/test-utils.ts
··· 170 170 // Key format: "workerIndex-fixtureName" 171 171 const workerServers = new Map<string, DevServer>(); 172 172 173 + // Track used ports to avoid collisions 174 + const usedPorts = new Set<number>(); 175 + 176 + /** 177 + * Generate a deterministic port offset based on fixture name. 178 + * This ensures each fixture gets a unique port range, avoiding collisions 179 + * when multiple fixtures run on the same worker. 180 + */ 181 + function getFixturePortOffset(fixtureName: string): number { 182 + // Simple hash function to get a number from the fixture name 183 + let hash = 0; 184 + for (let i = 0; i < fixtureName.length; i++) { 185 + const char = fixtureName.charCodeAt(i); 186 + hash = ((hash << 5) - hash) + char; 187 + hash = hash & hash; // Convert to 32bit integer 188 + } 189 + // Use modulo to keep the offset reasonable (0-99) 190 + return Math.abs(hash) % 100; 191 + } 192 + 193 + /** 194 + * Find an available port starting from the preferred port. 195 + */ 196 + function findAvailablePort(preferredPort: number): number { 197 + let port = preferredPort; 198 + while (usedPorts.has(port)) { 199 + port++; 200 + } 201 + usedPorts.add(port); 202 + return port; 203 + } 204 + 173 205 /** 174 206 * Create a test instance with a devServer fixture for a specific fixture. 175 207 * This allows each test file to use a different fixture while sharing the same pattern. 176 208 * 177 209 * @param fixtureName - Name of the fixture directory under e2e/fixtures/ 178 - * @param basePort - Starting port number (default: 1864). Each worker gets basePort + workerIndex 210 + * @param basePort - Starting port number (default: 1864). Each fixture gets a unique port based on its name. 179 211 * 180 212 * @example 181 213 * ```ts ··· 198 230 let server = workerServers.get(serverKey); 199 231 200 232 if (!server) { 201 - // Assign unique port based on worker index 202 - const port = basePort + workerIndex; 233 + // Calculate port based on fixture name hash + worker index to avoid collisions 234 + const fixtureOffset = getFixturePortOffset(fixtureName); 235 + const preferredPort = basePort + (workerIndex * 100) + fixtureOffset; 236 + const port = findAvailablePort(preferredPort); 203 237 204 238 server = await startDevServer({ 205 239 fixture: fixtureName,