Rust library to generate static websites

fix: some things

+158 -250
+22 -11
crates/maudit/src/build/state.rs
··· 103 103 let mut affected_routes = FxHashSet::default(); 104 104 105 105 for changed_file in changed_files { 106 - // Try exact match first 107 - if let Some(routes) = self.asset_to_routes.get(changed_file) { 108 - affected_routes.extend(routes.iter().cloned()); 109 - } 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(); 110 109 111 - // Try canonicalized path match 112 - if let Ok(canonical) = changed_file.canonicalize() { 113 - if let Some(routes) = self.asset_to_routes.get(&canonical) { 110 + // Try exact match with canonical path 111 + if let Some(canonical) = &canonical_changed { 112 + if let Some(routes) = self.asset_to_routes.get(canonical) { 114 113 affected_routes.extend(routes.iter().cloned()); 114 + continue; // Found exact match, no need for directory check 115 115 } 116 116 } 117 117 118 - // Also check if any tracked asset has this file as a prefix (for directories) 119 - for (asset_path, routes) in &self.asset_to_routes { 120 - if asset_path.starts_with(changed_file) { 121 - affected_routes.extend(routes.iter().cloned()); 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 + // 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); 129 + for (asset_path, routes) in &self.asset_to_routes { 130 + if asset_path.starts_with(canonical_dir) { 131 + affected_routes.extend(routes.iter().cloned()); 132 + } 122 133 } 123 134 } 124 135 }
+8 -1
e2e/fixtures/incremental-build/src/pages/about.rs
··· 1 1 use maud::{html, Markup}; 2 2 use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 3 4 4 5 #[route("/about")] 5 6 pub struct About; ··· 9 10 let _image = ctx.assets.add_image("src/assets/team.png"); 10 11 let _script = ctx.assets.add_script("src/assets/about.js"); 11 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 + 12 19 html! { 13 20 html { 14 21 head { 15 22 title { "About Page" } 16 23 } 17 - body { 24 + body data-build-id=(build_id) { 18 25 h1 id="title" { "About Us" } 19 26 p id="content" { "Learn more about us" } 20 27 }
+8 -1
e2e/fixtures/incremental-build/src/pages/blog.rs
··· 1 1 use maud::{html, Markup}; 2 2 use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 3 4 4 5 #[route("/blog")] 5 6 pub struct Blog; ··· 8 9 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 9 10 let _style = ctx.assets.add_style("src/assets/blog.css"); 10 11 12 + // Generate a unique build ID - uses nanoseconds for uniqueness 13 + let build_id = SystemTime::now() 14 + .duration_since(UNIX_EPOCH) 15 + .map(|d| d.as_nanos().to_string()) 16 + .unwrap_or_else(|_| "0".to_string()); 17 + 11 18 html! { 12 19 html { 13 20 head { 14 21 title { "Blog Page" } 15 22 } 16 - body { 23 + body data-build-id=(build_id) { 17 24 h1 id="title" { "Blog" } 18 25 p id="content" { "Read our latest posts" } 19 26 }
+8 -1
e2e/fixtures/incremental-build/src/pages/index.rs
··· 1 1 use maud::{html, Markup}; 2 2 use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 3 4 4 5 #[route("/")] 5 6 pub struct Index; ··· 10 11 let _script = ctx.assets.add_script("src/assets/main.js"); 11 12 let _style = ctx.assets.add_style("src/assets/styles.css"); 12 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 + 13 20 html! { 14 21 html { 15 22 head { 16 23 title { "Home Page" } 17 24 } 18 - body { 25 + body data-build-id=(build_id) { 19 26 h1 id="title" { "Home Page" } 20 27 p id="content" { "Welcome to the home page" } 21 28 }
+112 -236
e2e/tests/incremental-build.spec.ts
··· 1 1 import { expect } from "@playwright/test"; 2 2 import { createTestWithFixture } from "./test-utils"; 3 - import { readFileSync, writeFileSync, statSync } from "node:fs"; 3 + import { readFileSync, writeFileSync } from "node:fs"; 4 4 import { resolve, dirname } from "node:path"; 5 5 import { fileURLToPath } from "node:url"; 6 6 ··· 10 10 // Create test instance with incremental-build fixture 11 11 const test = createTestWithFixture("incremental-build"); 12 12 13 - test.describe.configure({ mode: "serial" }); 13 + // Allow retries for timing-sensitive tests 14 + test.describe.configure({ mode: "serial", retries: 2 }); 14 15 15 16 /** 16 - * Wait for dev server to complete a build/rerun by polling logs 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. 17 19 */ 18 - async function waitForBuildComplete(devServer: any, timeoutMs = 20000): Promise<string[]> { 20 + async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> { 19 21 const startTime = Date.now(); 20 22 23 + // Phase 1: Wait for build to start 21 24 while (Date.now() - startTime < timeoutMs) { 22 - const logs = devServer.getLogs(100); 25 + const logs = devServer.getLogs(200); 23 26 const logsText = logs.join("\n").toLowerCase(); 24 27 25 - // Look for completion messages 28 + if (logsText.includes("rerunning") || 29 + logsText.includes("rebuilding") || 30 + logsText.includes("files changed")) { 31 + break; 32 + } 33 + 34 + await new Promise(resolve => setTimeout(resolve, 50)); 35 + } 36 + 37 + // Phase 2: Wait for build to finish 38 + while (Date.now() - startTime < timeoutMs) { 39 + const logs = devServer.getLogs(200); 40 + const logsText = logs.join("\n").toLowerCase(); 41 + 26 42 if (logsText.includes("finished") || 27 43 logsText.includes("rerun finished") || 28 44 logsText.includes("build finished")) { 29 - return logs; 45 + // Wait for filesystem to fully sync 46 + await new Promise(resolve => setTimeout(resolve, 500)); 47 + return devServer.getLogs(200); 30 48 } 31 49 32 - // Wait 100ms before checking again 33 50 await new Promise(resolve => setTimeout(resolve, 100)); 34 51 } 35 52 36 53 throw new Error(`Build did not complete within ${timeoutMs}ms`); 37 54 } 38 55 56 + /** 57 + * Extract the build ID from an HTML file. 58 + */ 59 + function getBuildId(htmlPath: string): string | null { 60 + try { 61 + const content = readFileSync(htmlPath, "utf-8"); 62 + const match = content.match(/data-build-id="(\d+)"/); 63 + return match ? match[1] : null; 64 + } catch { 65 + return null; 66 + } 67 + } 68 + 69 + /** 70 + * Check if logs indicate incremental build was used 71 + */ 72 + function isIncrementalBuild(logs: string[]): boolean { 73 + return logs.join("\n").toLowerCase().includes("incremental build"); 74 + } 75 + 76 + /** 77 + * Get the number of affected routes from logs 78 + */ 79 + function getAffectedRouteCount(logs: string[]): number { 80 + const logsText = logs.join("\n"); 81 + const match = logsText.match(/Rebuilding (\d+) affected routes/i); 82 + return match ? parseInt(match[1], 10) : -1; 83 + } 84 + 39 85 test.describe("Incremental Build", () => { 40 - // Increase timeout for these tests since they involve compilation 41 - test.setTimeout(60000); 86 + test.setTimeout(180000); 42 87 43 88 const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build"); 44 - const stylesPath = resolve(fixturePath, "src", "assets", "styles.css"); 89 + 90 + // Asset paths 45 91 const blogStylesPath = resolve(fixturePath, "src", "assets", "blog.css"); 46 - const mainScriptPath = resolve(fixturePath, "src", "assets", "main.js"); 47 - const aboutScriptPath = resolve(fixturePath, "src", "assets", "about.js"); 48 - const logoPath = resolve(fixturePath, "src", "assets", "logo.png"); 49 - const teamPath = resolve(fixturePath, "src", "assets", "team.png"); 50 92 51 - const indexHtmlPath = resolve(fixturePath, "dist", "index.html"); 52 - const aboutHtmlPath = resolve(fixturePath, "dist", "about", "index.html"); 53 - const blogHtmlPath = resolve(fixturePath, "dist", "blog", "index.html"); 93 + // Output HTML paths 94 + const htmlPaths = { 95 + index: resolve(fixturePath, "dist", "index.html"), 96 + about: resolve(fixturePath, "dist", "about", "index.html"), 97 + blog: resolve(fixturePath, "dist", "blog", "index.html"), 98 + }; 54 99 55 - let originalStylesContent: string; 56 - let originalBlogStylesContent: string; 57 - let originalMainScriptContent: string; 58 - let originalAboutScriptContent: string; 59 - let originalLogoContent: Buffer; 60 - let originalTeamContent: Buffer; 100 + // Original content storage 101 + let originalBlogStyles: string; 61 102 62 103 test.beforeAll(async () => { 63 - // Save original content 64 - originalStylesContent = readFileSync(stylesPath, "utf-8"); 65 - originalBlogStylesContent = readFileSync(blogStylesPath, "utf-8"); 66 - originalMainScriptContent = readFileSync(mainScriptPath, "utf-8"); 67 - originalAboutScriptContent = readFileSync(aboutScriptPath, "utf-8"); 68 - originalLogoContent = readFileSync(logoPath); 69 - originalTeamContent = readFileSync(teamPath); 70 - 71 - // Ensure files are in original state 72 - writeFileSync(stylesPath, originalStylesContent, "utf-8"); 73 - writeFileSync(blogStylesPath, originalBlogStylesContent, "utf-8"); 74 - writeFileSync(mainScriptPath, originalMainScriptContent, "utf-8"); 75 - writeFileSync(aboutScriptPath, originalAboutScriptContent, "utf-8"); 76 - writeFileSync(logoPath, originalLogoContent); 77 - writeFileSync(teamPath, originalTeamContent); 78 - }); 79 - 80 - test.afterEach(async ({ devServer }) => { 81 - // Restore original content after each test 82 - writeFileSync(stylesPath, originalStylesContent, "utf-8"); 83 - writeFileSync(blogStylesPath, originalBlogStylesContent, "utf-8"); 84 - writeFileSync(mainScriptPath, originalMainScriptContent, "utf-8"); 85 - writeFileSync(aboutScriptPath, originalAboutScriptContent, "utf-8"); 86 - writeFileSync(logoPath, originalLogoContent); 87 - writeFileSync(teamPath, originalTeamContent); 88 - 89 - // Wait for build if devServer is available 90 - if (devServer) { 91 - try { 92 - devServer.clearLogs(); 93 - await waitForBuildComplete(devServer); 94 - } catch (error) { 95 - console.warn("Failed to wait for build completion in afterEach:", error); 96 - } 97 - } 104 + originalBlogStyles = readFileSync(blogStylesPath, "utf-8"); 105 + // Ensure file is in original state 106 + writeFileSync(blogStylesPath, originalBlogStyles, "utf-8"); 98 107 }); 99 108 100 109 test.afterAll(async () => { 101 110 // Restore original content 102 - writeFileSync(stylesPath, originalStylesContent, "utf-8"); 103 - writeFileSync(blogStylesPath, originalBlogStylesContent, "utf-8"); 104 - writeFileSync(mainScriptPath, originalMainScriptContent, "utf-8"); 105 - writeFileSync(aboutScriptPath, originalAboutScriptContent, "utf-8"); 106 - writeFileSync(logoPath, originalLogoContent); 107 - writeFileSync(teamPath, originalTeamContent); 108 - }); 109 - 110 - test("should perform full build on first run after recompilation", async ({ devServer }) => { 111 - // Clear logs to track what happens after initial startup 112 - devServer.clearLogs(); 113 - 114 - // Modify a file to trigger a rebuild 115 - writeFileSync(stylesPath, originalStylesContent + "\n/* comment */", "utf-8"); 116 - 117 - // Wait for rebuild 118 - const logs = await waitForBuildComplete(devServer, 20000); 119 - const logsText = logs.join("\n").toLowerCase(); 120 - 121 - // After the first change post-startup, we should see an incremental build message 122 - expect(logsText).toContain("incremental build"); 123 - }); 124 - 125 - test("should only rebuild affected route when CSS changes", async ({ devServer }) => { 126 - // First, do a change to ensure we have build state 127 - writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8"); 128 - await waitForBuildComplete(devServer); 129 - 130 - // Get modification times before change 131 - const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs; 132 - const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs; 133 - const blogMtimeBefore = statSync(blogHtmlPath).mtimeMs; 134 - 135 - // Wait longer to ensure timestamps differ and debouncer completes 136 - await new Promise(resolve => setTimeout(resolve, 500)); 137 - 138 - // Clear logs 139 - devServer.clearLogs(); 140 - 141 - // Change blog.css (only used by /blog route) 142 - writeFileSync(blogStylesPath, originalBlogStylesContent + "\n/* modified */", "utf-8"); 143 - 144 - // Wait for rebuild 145 - const logs = await waitForBuildComplete(devServer, 20000); 146 - const logsText = logs.join("\n").toLowerCase(); 147 - 148 - // Should be incremental build 149 - expect(logsText).toContain("incremental build"); 150 - 151 - // Get modification times after change 152 - const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs; 153 - const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs; 154 - const blogMtimeAfter = statSync(blogHtmlPath).mtimeMs; 155 - 156 - // Index and About should NOT be rebuilt (same mtime) 157 - expect(indexMtimeAfter).toBe(indexMtimeBefore); 158 - expect(aboutMtimeAfter).toBe(aboutMtimeBefore); 159 - 160 - // Blog should be rebuilt (different mtime) 161 - expect(blogMtimeAfter).toBeGreaterThan(blogMtimeBefore); 111 + writeFileSync(blogStylesPath, originalBlogStyles, "utf-8"); 162 112 }); 163 113 164 - test("should rebuild multiple routes when shared asset changes", async ({ devServer }) => { 165 - // First, do a change to ensure we have build state 166 - writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8"); 167 - await waitForBuildComplete(devServer); 114 + test("incremental builds only rebuild affected routes", async ({ devServer }) => { 115 + let testCounter = 0; 168 116 169 - // Get modification times before change 170 - const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs; 171 - const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs; 172 - const blogMtimeBefore = statSync(blogHtmlPath).mtimeMs; 117 + async function triggerChange(suffix: string) { 118 + testCounter++; 119 + devServer.clearLogs(); 120 + writeFileSync(blogStylesPath, originalBlogStyles + `\n/* test-${testCounter}-${suffix} */`, "utf-8"); 121 + return await waitForBuildComplete(devServer, 30000); 122 + } 173 123 174 - // Wait longer to ensure timestamps differ and debouncer completes 124 + // ======================================== 125 + // SETUP: Establish incremental build state 126 + // ======================================== 127 + // First change triggers a full build (no previous state) 128 + await triggerChange("init"); 175 129 await new Promise(resolve => setTimeout(resolve, 500)); 176 130 177 - // Clear logs 178 - devServer.clearLogs(); 179 - 180 - // Change styles.css (used by /index route) 181 - writeFileSync(stylesPath, originalStylesContent + "\n/* modified */", "utf-8"); 182 - 183 - // Wait for rebuild 184 - const logs = await waitForBuildComplete(devServer, 20000); 185 - const logsText = logs.join("\n").toLowerCase(); 186 - 187 - // Should be incremental build 188 - expect(logsText).toContain("incremental build"); 189 - 190 - // Get modification times after change 191 - const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs; 192 - const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs; 193 - const blogMtimeAfter = statSync(blogHtmlPath).mtimeMs; 194 - 195 - // Index should be rebuilt (uses styles.css) 196 - expect(indexMtimeAfter).toBeGreaterThan(indexMtimeBefore); 197 - 198 - // About and Blog should NOT be rebuilt 199 - expect(aboutMtimeAfter).toBe(aboutMtimeBefore); 200 - expect(blogMtimeAfter).toBe(blogMtimeBefore); 201 - }); 202 - 203 - test("should rebuild affected route when script changes", async ({ devServer }) => { 204 - // First, do a change to ensure we have build state 205 - writeFileSync(mainScriptPath, originalMainScriptContent + "\n// setup", "utf-8"); 206 - await waitForBuildComplete(devServer); 207 - 208 - // Get modification times before change 209 - const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs; 210 - const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs; 211 - 212 - // Wait longer to ensure timestamps differ and debouncer completes 131 + // Second change should be incremental (state now exists) 132 + let logs = await triggerChange("setup"); 133 + expect(isIncrementalBuild(logs)).toBe(true); 213 134 await new Promise(resolve => setTimeout(resolve, 500)); 214 - 215 - // Clear logs 216 - devServer.clearLogs(); 217 - 218 - // Change about.js (only used by /about route) 219 - writeFileSync(aboutScriptPath, originalAboutScriptContent + "\n// modified", "utf-8"); 220 - 221 - // Wait for rebuild 222 - const logs = await waitForBuildComplete(devServer, 20000); 223 - const logsText = logs.join("\n").toLowerCase(); 224 - 225 - // Should be incremental build 226 - expect(logsText).toContain("incremental build"); 227 - 228 - // Get modification times after change 229 - const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs; 230 - const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs; 231 - 232 - // Index should NOT be rebuilt 233 - expect(indexMtimeAfter).toBe(indexMtimeBefore); 234 - 235 - // About should be rebuilt 236 - expect(aboutMtimeAfter).toBeGreaterThan(aboutMtimeBefore); 237 - }); 238 135 239 - test("should rebuild affected route when image changes", async ({ devServer }) => { 240 - // First, do a change to ensure we have build state 241 - writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8"); 242 - await waitForBuildComplete(devServer); 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); 243 143 244 - // Get modification times before change 245 - const indexMtimeBefore = statSync(indexHtmlPath).mtimeMs; 246 - const aboutMtimeBefore = statSync(aboutHtmlPath).mtimeMs; 144 + expect(beforeIndex).not.toBeNull(); 145 + expect(beforeAbout).not.toBeNull(); 146 + expect(beforeBlog).not.toBeNull(); 247 147 248 - // Wait longer to ensure timestamps differ and debouncer completes 148 + // Wait a bit more to ensure clean slate 249 149 await new Promise(resolve => setTimeout(resolve, 500)); 250 150 251 - // Clear logs 252 - devServer.clearLogs(); 151 + // Trigger the change 152 + logs = await triggerChange("final"); 253 153 254 - // "Change" team.png (used by /about route) 255 - // We'll just write it again with same content but new mtime 256 - writeFileSync(teamPath, originalTeamContent); 154 + // Verify it was an incremental build 155 + expect(isIncrementalBuild(logs)).toBe(true); 257 156 258 - // Wait for rebuild 259 - const logs = await waitForBuildComplete(devServer, 20000); 260 - const logsText = logs.join("\n").toLowerCase(); 157 + // Verify exactly 1 route was rebuilt (from logs) 158 + const routeCount = getAffectedRouteCount(logs); 159 + expect(routeCount).toBe(1); 261 160 262 - // Should be incremental build 263 - expect(logsText).toContain("incremental build"); 264 - 265 - // Get modification times after change 266 - const indexMtimeAfter = statSync(indexHtmlPath).mtimeMs; 267 - const aboutMtimeAfter = statSync(aboutHtmlPath).mtimeMs; 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); 268 165 269 - // Index should NOT be rebuilt 270 - expect(indexMtimeAfter).toBe(indexMtimeBefore); 166 + // Index and about should NOT have been rebuilt 167 + expect(afterIndex).toBe(beforeIndex); 168 + expect(afterAbout).toBe(beforeAbout); 271 169 272 - // About should be rebuilt 273 - expect(aboutMtimeAfter).toBeGreaterThan(aboutMtimeBefore); 274 - }); 275 - 276 - test("should preserve bundler inputs across incremental builds", async ({ devServer }) => { 277 - // First, do a change to ensure we have build state 278 - writeFileSync(stylesPath, originalStylesContent + "\n/* setup */", "utf-8"); 279 - await waitForBuildComplete(devServer); 280 - 281 - // Clear logs 282 - devServer.clearLogs(); 283 - 284 - // Change only blog.css (blog route only) 285 - writeFileSync(blogStylesPath, originalBlogStylesContent + "\n/* modified */", "utf-8"); 286 - 287 - // Wait for rebuild 288 - const logs = await waitForBuildComplete(devServer, 20000); 289 - const logsText = logs.join("\n"); 290 - 291 - // Check that logs mention merging with previous bundler inputs 292 - // This ensures that even though only blog route was rebuilt, 293 - // all assets from the previous build are still bundled 294 - expect(logsText).toContain("Merging with"); 295 - expect(logsText).toContain("previous bundler inputs"); 170 + // Blog SHOULD have been rebuilt 171 + expect(afterBlog).not.toBe(beforeBlog); 296 172 }); 297 173 });