Rust library to generate static websites

fix: folders

+289 -156
+1 -1
.github/workflows/benchmark.yaml
··· 41 41 uses: actions/setup-node@v4 42 42 with: 43 43 node-version: latest 44 - cache: 'pnpm' 44 + cache: "pnpm" 45 45 46 46 - name: Install dependencies 47 47 run: pnpm install
+4 -4
.github/workflows/ci.yaml
··· 38 38 uses: actions/setup-node@v4 39 39 with: 40 40 node-version: latest 41 - cache: 'pnpm' 41 + cache: "pnpm" 42 42 43 43 - name: Install dependencies 44 44 run: pnpm install ··· 66 66 uses: actions/setup-node@v4 67 67 with: 68 68 node-version: latest 69 - cache: 'pnpm' 69 + cache: "pnpm" 70 70 71 71 - name: Install dependencies 72 72 run: pnpm install ··· 94 94 uses: actions/setup-node@v4 95 95 with: 96 96 node-version: latest 97 - cache: 'pnpm' 97 + cache: "pnpm" 98 98 99 99 - name: Install dependencies 100 100 run: pnpm install ··· 126 126 uses: actions/setup-node@v4 127 127 with: 128 128 node-version: latest 129 - cache: 'pnpm' 129 + cache: "pnpm" 130 130 131 131 - name: Install dependencies 132 132 run: pnpm install
+1 -1
.github/workflows/release.yml
··· 30 30 uses: actions/setup-node@v4 31 31 with: 32 32 node-version: latest 33 - cache: 'pnpm' 33 + cache: "pnpm" 34 34 35 35 - name: Install dependencies 36 36 run: pnpm install
+2 -6
.vscode/extensions.json
··· 1 1 { 2 - "recommendations": [ 3 - "oxc.oxc-vscode", 4 - "TypeScriptTeam.native-preview", 5 - "rust-lang.rust-analyzer" 6 - ] 7 - } 2 + "recommendations": ["oxc.oxc-vscode", "TypeScriptTeam.native-preview", "rust-lang.rust-analyzer"] 3 + }
+14 -14
.vscode/settings.json
··· 1 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 - } 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 + }
+39 -6
crates/maudit-cli/src/dev.rs
··· 15 15 fs, 16 16 path::{Path, PathBuf}, 17 17 }; 18 - use tokio::{ 19 - signal, 20 - sync::mpsc::channel, 21 - task::JoinHandle, 22 - }; 18 + use tokio::{signal, sync::mpsc::channel, task::JoinHandle}; 23 19 use tracing::{error, info}; 24 20 25 21 use crate::dev::build::BuildManager; ··· 107 103 108 104 match result { 109 105 Ok(events) => { 110 - info!(name: "watch", "Received {} events: {:?}", events.len(), events); 111 106 // TODO: Handle rescan events, I don't fully understand the implication of them yet 112 107 // some issues: 113 108 // - https://github.com/notify-rs/notify/issues/434 ··· 176 171 .filter(|e| should_rebuild_for_event(e)) 177 172 .flat_map(|e| e.paths.iter().cloned()) 178 173 .collect(); 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); 179 178 180 179 // Deduplicate paths 181 180 changed_paths.sort(); ··· 300 299 } 301 300 302 301 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 + } 303 336 } 304 337 305 338 async fn shutdown_signal() {
+34 -12
crates/maudit-cli/src/dev/build.rs
··· 101 101 102 102 // Log that we're doing an incremental build 103 103 info!(name: "build", "Incremental build: {} files changed", changed_paths.len()); 104 - info!(name: "build", "Changed files: {:?}", changed_paths); 104 + debug!(name: "build", "Changed files: {:?}", changed_paths); 105 105 info!(name: "build", "Rerunning binary without recompilation..."); 106 106 107 107 self.state ··· 172 172 self.internal_build(false).await 173 173 } 174 174 175 - async fn internal_build(&self, is_initial: bool) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 175 + async fn internal_build( 176 + &self, 177 + is_initial: bool, 178 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 176 179 // Cancel any existing build immediately 177 180 let cancel = CancellationToken::new(); 178 181 { ··· 276 279 let stderr_bytes = stderr_task.await.unwrap_or_default(); 277 280 278 281 let duration = build_start_time.elapsed(); 279 - let formatted_elapsed_time = format_elapsed_time( 280 - duration, 281 - &FormatElapsedTimeOptions::default_dev(), 282 - ); 282 + let formatted_elapsed_time = 283 + format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev()); 283 284 284 285 if status.success() { 285 - let build_type = if is_initial { "Initial build" } else { "Rebuild" }; 286 + let build_type = if is_initial { 287 + "Initial build" 288 + } else { 289 + "Rebuild" 290 + }; 286 291 info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time); 287 - self.state.status_manager.update(StatusType::Success, "Build finished successfully").await; 292 + self.state 293 + .status_manager 294 + .update(StatusType::Success, "Build finished successfully") 295 + .await; 288 296 289 297 self.update_dependency_tracker().await; 290 298 ··· 294 302 // Raw stderr sometimes has something to say whenever cargo fails 295 303 println!("{}", stderr_str); 296 304 297 - let build_type = if is_initial { "Initial build" } else { "Rebuild" }; 305 + let build_type = if is_initial { 306 + "Initial build" 307 + } else { 308 + "Rebuild" 309 + }; 298 310 error!(name: "build", "{} failed with errors {}", build_type, formatted_elapsed_time); 299 311 300 312 if is_initial { 301 313 error!(name: "build", "Initial build needs to succeed before we can start the dev server"); 302 - self.state.status_manager.update(StatusType::Error, "Initial build failed - fix errors and save to retry").await; 314 + self.state 315 + .status_manager 316 + .update( 317 + StatusType::Error, 318 + "Initial build failed - fix errors and save to retry", 319 + ) 320 + .await; 303 321 } else { 304 - self.state.status_manager.update(StatusType::Error, &rendered_messages.join("\n")).await; 322 + self.state 323 + .status_manager 324 + .update(StatusType::Error, &rendered_messages.join("\n")) 325 + .await; 305 326 } 306 327 307 328 Ok(false) ··· 341 362 } 342 363 } 343 364 344 - fn get_binary_name_from_cargo_toml() -> Result<String, Box<dyn std::error::Error + Send + Sync>> { 365 + fn get_binary_name_from_cargo_toml() -> Result<String, Box<dyn std::error::Error + Send + Sync>> 366 + { 345 367 let cargo_toml_path = PathBuf::from("Cargo.toml"); 346 368 if !cargo_toml_path.exists() { 347 369 return Err("Cargo.toml not found in current directory".into());
+7 -7
crates/maudit-cli/src/dev/server.rs
··· 346 346 ws.on_upgrade(move |socket| handle_socket(socket, addr, state.status_manager)) 347 347 } 348 348 349 - async fn handle_socket( 350 - socket: WebSocket, 351 - who: SocketAddr, 352 - status_manager: StatusManager, 353 - ) { 349 + async fn handle_socket(socket: WebSocket, who: SocketAddr, status_manager: StatusManager) { 354 350 let (mut sender, mut receiver) = socket.split(); 355 351 356 352 // Send current persistent status to new connection if there is one ··· 424 420 async fn test_status_manager_update_error_persists() { 425 421 let manager = StatusManager::new(); 426 422 427 - manager.update(StatusType::Error, "Something went wrong").await; 423 + manager 424 + .update(StatusType::Error, "Something went wrong") 425 + .await; 428 426 429 427 let status = manager.get_current().await; 430 428 assert!(status.is_some()); ··· 521 519 let manager2 = manager1.clone(); 522 520 523 521 // Update via one clone 524 - manager1.update(StatusType::Error, "Error from clone 1").await; 522 + manager1 523 + .update(StatusType::Error, "Error from clone 1") 524 + .await; 525 525 526 526 // Should be visible via the other clone 527 527 let status = manager2.get_current().await;
+1 -1
crates/maudit/Cargo.toml
··· 50 50 rayon = "1.11.0" 51 51 rapidhash = "4.2.1" 52 52 pathdiff = "0.2.3" 53 - rolldown_plugin_replace = {package = "brk_rolldown_plugin_replace", version = "0.8.0"} 53 + rolldown_plugin_replace = { package = "brk_rolldown_plugin_replace", version = "0.8.0" } 54 54 55 55 [dev-dependencies] 56 56 tempfile = "3.24.0"
+2 -4
crates/maudit/src/build.rs
··· 800 800 let asset_path = PathBuf::from(original_file); 801 801 if let Ok(canonical_asset) = asset_path.canonicalize() { 802 802 for route in &all_bundler_routes { 803 - build_state.track_asset( 804 - canonical_asset.clone(), 805 - route.clone(), 806 - ); 803 + build_state 804 + .track_asset(canonical_asset.clone(), route.clone()); 807 805 } 808 806 asset_count += 1; 809 807 }
+3
e2e/README.md
··· 13 13 ## Running Tests 14 14 15 15 The tests will automatically: 16 + 16 17 1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`) 17 18 2. Start the Maudit dev server on the test fixture site 18 19 3. Run the tests ··· 46 47 ## Features Tested 47 48 48 49 ### Basic Prefetch 50 + 49 51 - Creating link elements with `rel="prefetch"` 50 52 - Preventing duplicate prefetches 51 53 - Skipping current page prefetch 52 54 - Blocking cross-origin prefetches 53 55 54 56 ### Prerendering (Chromium only) 57 + 55 58 - Creating `<script type="speculationrules">` elements 56 59 - Different eagerness levels (immediate, eager, moderate, conservative) 57 60 - Fallback to link prefetch on non-Chromium browsers
+1 -1
e2e/fixtures/incremental-build/src/assets/about.js
··· 1 1 // About script 2 - console.log('About script loaded'); 2 + console.log("About script loaded");
+3 -3
e2e/fixtures/incremental-build/src/assets/blog.css
··· 1 1 /* Blog styles */ 2 2 .blog-post { 3 - margin: 20px; 3 + margin: 20px; 4 4 } 5 5 6 6 /* Background image referenced via url() - tests CSS asset dependency tracking */ 7 7 .blog-header { 8 - background-image: url('./bg.png'); 9 - background-size: cover; 8 + background-image: url("./bg.png"); 9 + background-size: cover; 10 10 }
+3 -3
e2e/fixtures/incremental-build/src/assets/main.js
··· 1 1 // Main script 2 - import { greet } from './utils.js'; 2 + import { greet } from "./utils.js"; 3 3 4 - console.log('Main script loaded'); 5 - console.log(greet('World')); 4 + console.log("Main script loaded"); 5 + console.log(greet("World"));
+1 -1
e2e/fixtures/incremental-build/src/assets/styles.css
··· 1 1 /* Main styles */ 2 2 body { 3 - font-family: sans-serif; 3 + font-family: sans-serif; 4 4 } 5 5 /* test7 */ 6 6 /* test */
+1 -1
e2e/fixtures/incremental-build/src/assets/utils.js
··· 1 1 // Utility functions 2 2 export function greet(name) { 3 - return `Hello, ${name}!`; 3 + return `Hello, ${name}!`; 4 4 }
+11 -9
e2e/tests/hot-reload.spec.ts
··· 17 17 */ 18 18 async function waitForBuildComplete(devServer: any, timeoutMs = 20000): Promise<string[]> { 19 19 const startTime = Date.now(); 20 - 20 + 21 21 while (Date.now() - startTime < timeoutMs) { 22 22 const logs = devServer.getLogs(100); 23 23 const logsText = logs.join("\n").toLowerCase(); 24 - 24 + 25 25 // Look for completion messages 26 - if (logsText.includes("finished") || 27 - logsText.includes("rerun finished") || 28 - logsText.includes("build finished")) { 26 + if ( 27 + logsText.includes("finished") || 28 + logsText.includes("rerun finished") || 29 + logsText.includes("build finished") 30 + ) { 29 31 return logs; 30 32 } 31 - 33 + 32 34 // Wait 100ms before checking again 33 - await new Promise(resolve => setTimeout(resolve, 100)); 35 + await new Promise((resolve) => setTimeout(resolve, 100)); 34 36 } 35 - 37 + 36 38 throw new Error(`Build did not complete within ${timeoutMs}ms`); 37 39 } 38 40 ··· 65 67 writeFileSync(indexPath, originalIndexContent, "utf-8"); 66 68 writeFileSync(mainPath, originalMainContent, "utf-8"); 67 69 writeFileSync(dataPath, originalDataContent, "utf-8"); 68 - 70 + 69 71 // Only wait for build if devServer is available (startup might have failed) 70 72 if (devServer) { 71 73 try {
+156 -77
e2e/tests/incremental-build.spec.ts
··· 1 1 import { expect } from "@playwright/test"; 2 2 import { createTestWithFixture } from "./test-utils"; 3 - import { readFileSync, writeFileSync, copyFileSync } from "node:fs"; 3 + import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, existsSync } from "node:fs"; 4 4 import { resolve, dirname } from "node:path"; 5 5 import { fileURLToPath } from "node:url"; 6 6 ··· 19 19 */ 20 20 async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> { 21 21 const startTime = Date.now(); 22 - 22 + 23 23 // Phase 1: Wait for build to start 24 24 while (Date.now() - startTime < timeoutMs) { 25 25 const logs = devServer.getLogs(200); 26 26 const logsText = logs.join("\n").toLowerCase(); 27 - 28 - if (logsText.includes("rerunning") || 29 - logsText.includes("rebuilding") || 30 - logsText.includes("files changed")) { 27 + 28 + if ( 29 + logsText.includes("rerunning") || 30 + logsText.includes("rebuilding") || 31 + logsText.includes("files changed") 32 + ) { 31 33 break; 32 34 } 33 - 34 - await new Promise(resolve => setTimeout(resolve, 50)); 35 + 36 + await new Promise((resolve) => setTimeout(resolve, 50)); 35 37 } 36 - 38 + 37 39 // Phase 2: Wait for build to finish 38 40 while (Date.now() - startTime < timeoutMs) { 39 41 const logs = devServer.getLogs(200); 40 42 const logsText = logs.join("\n").toLowerCase(); 41 - 42 - if (logsText.includes("finished") || 43 - logsText.includes("rerun finished") || 44 - logsText.includes("build finished")) { 43 + 44 + if ( 45 + logsText.includes("finished") || 46 + logsText.includes("rerun finished") || 47 + logsText.includes("build finished") 48 + ) { 45 49 // Wait for filesystem to fully sync 46 - await new Promise(resolve => setTimeout(resolve, 500)); 50 + await new Promise((resolve) => setTimeout(resolve, 500)); 47 51 return devServer.getLogs(200); 48 52 } 49 - 50 - await new Promise(resolve => setTimeout(resolve, 100)); 53 + 54 + await new Promise((resolve) => setTimeout(resolve, 100)); 51 55 } 52 - 56 + 53 57 throw new Error(`Build did not complete within ${timeoutMs}ms`); 54 58 } 55 59 ··· 87 91 */ 88 92 async function setupIncrementalState( 89 93 devServer: any, 90 - triggerChange: (suffix: string) => Promise<string[]> 94 + triggerChange: (suffix: string) => Promise<string[]>, 91 95 ): Promise<void> { 92 96 // First change triggers a full build (no previous state) 93 97 await triggerChange("init"); 94 - await new Promise(resolve => setTimeout(resolve, 500)); 95 - 98 + await new Promise((resolve) => setTimeout(resolve, 500)); 99 + 96 100 // Second change should be incremental (state now exists) 97 101 const logs = await triggerChange("setup"); 98 102 expect(isIncrementalBuild(logs)).toBe(true); 99 - await new Promise(resolve => setTimeout(resolve, 500)); 103 + await new Promise((resolve) => setTimeout(resolve, 500)); 100 104 } 101 105 102 106 /** ··· 114 118 test.setTimeout(180000); 115 119 116 120 const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build"); 117 - 121 + 118 122 // Asset paths 119 123 const assets = { 120 124 blogCss: resolve(fixturePath, "src", "assets", "blog.css"), ··· 126 130 teamPng: resolve(fixturePath, "src", "assets", "team.png"), 127 131 bgPng: resolve(fixturePath, "src", "assets", "bg.png"), 128 132 }; 129 - 133 + 130 134 // Output HTML paths 131 135 const htmlPaths = { 132 136 index: resolve(fixturePath, "dist", "index.html"), 133 137 about: resolve(fixturePath, "dist", "about", "index.html"), 134 138 blog: resolve(fixturePath, "dist", "blog", "index.html"), 135 139 }; 136 - 140 + 137 141 // Original content storage 138 142 const originals: Record<string, string | Buffer> = {}; 139 143 ··· 166 170 // ============================================================ 167 171 test("CSS file change rebuilds only routes using it", async ({ devServer }) => { 168 172 let testCounter = 0; 169 - 173 + 170 174 async function triggerChange(suffix: string) { 171 175 testCounter++; 172 176 devServer.clearLogs(); 173 177 writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 174 178 return await waitForBuildComplete(devServer, 30000); 175 179 } 176 - 180 + 177 181 await setupIncrementalState(devServer, triggerChange); 178 182 179 183 // Record build IDs before ··· 181 185 expect(before.index).not.toBeNull(); 182 186 expect(before.about).not.toBeNull(); 183 187 expect(before.blog).not.toBeNull(); 184 - 185 - await new Promise(resolve => setTimeout(resolve, 500)); 186 - 188 + 189 + await new Promise((resolve) => setTimeout(resolve, 500)); 190 + 187 191 // Trigger the change 188 192 const logs = await triggerChange("final"); 189 - 193 + 190 194 // Verify incremental build with 1 route 191 195 expect(isIncrementalBuild(logs)).toBe(true); 192 196 expect(getAffectedRouteCount(logs)).toBe(1); 193 - 197 + 194 198 // Verify only blog was rebuilt 195 199 const after = recordBuildIds(htmlPaths); 196 200 expect(after.index).toBe(before.index); ··· 203 207 // ============================================================ 204 208 test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => { 205 209 let testCounter = 0; 206 - 210 + 207 211 async function triggerChange(suffix: string) { 208 212 testCounter++; 209 213 devServer.clearLogs(); 210 214 writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`); 211 215 return await waitForBuildComplete(devServer, 30000); 212 216 } 213 - 217 + 214 218 await setupIncrementalState(devServer, triggerChange); 215 219 216 220 const before = recordBuildIds(htmlPaths); 217 221 expect(before.index).not.toBeNull(); 218 - 219 - await new Promise(resolve => setTimeout(resolve, 500)); 220 - 222 + 223 + await new Promise((resolve) => setTimeout(resolve, 500)); 224 + 221 225 const logs = await triggerChange("final"); 222 - 226 + 223 227 // Verify incremental build with 1 route 224 228 expect(isIncrementalBuild(logs)).toBe(true); 225 229 expect(getAffectedRouteCount(logs)).toBe(1); 226 - 230 + 227 231 // Only index should be rebuilt (uses main.js which imports utils.js) 228 232 const after = recordBuildIds(htmlPaths); 229 233 expect(after.about).toBe(before.about); ··· 236 240 // ============================================================ 237 241 test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => { 238 242 let testCounter = 0; 239 - 243 + 240 244 async function triggerChange(suffix: string) { 241 245 testCounter++; 242 246 devServer.clearLogs(); 243 247 writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 244 248 return await waitForBuildComplete(devServer, 30000); 245 249 } 246 - 250 + 247 251 await setupIncrementalState(devServer, triggerChange); 248 252 249 253 const before = recordBuildIds(htmlPaths); 250 254 expect(before.about).not.toBeNull(); 251 - 252 - await new Promise(resolve => setTimeout(resolve, 500)); 253 - 255 + 256 + await new Promise((resolve) => setTimeout(resolve, 500)); 257 + 254 258 const logs = await triggerChange("final"); 255 - 259 + 256 260 // Verify incremental build with 1 route 257 261 expect(isIncrementalBuild(logs)).toBe(true); 258 262 expect(getAffectedRouteCount(logs)).toBe(1); 259 - 263 + 260 264 // Only about should be rebuilt 261 265 const after = recordBuildIds(htmlPaths); 262 266 expect(after.index).toBe(before.index); ··· 269 273 // ============================================================ 270 274 test("shared asset change rebuilds all routes using it", async ({ devServer }) => { 271 275 let testCounter = 0; 272 - 276 + 273 277 async function triggerChange(suffix: string) { 274 278 testCounter++; 275 279 devServer.clearLogs(); 276 - writeFileSync(assets.stylesCss, originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`); 280 + writeFileSync( 281 + assets.stylesCss, 282 + originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`, 283 + ); 277 284 return await waitForBuildComplete(devServer, 30000); 278 285 } 279 - 286 + 280 287 await setupIncrementalState(devServer, triggerChange); 281 288 282 289 const before = recordBuildIds(htmlPaths); 283 290 expect(before.index).not.toBeNull(); 284 291 expect(before.about).not.toBeNull(); 285 - 286 - await new Promise(resolve => setTimeout(resolve, 500)); 287 - 292 + 293 + await new Promise((resolve) => setTimeout(resolve, 500)); 294 + 288 295 const logs = await triggerChange("final"); 289 - 296 + 290 297 // Verify incremental build with 2 routes (/ and /about both use styles.css) 291 298 expect(isIncrementalBuild(logs)).toBe(true); 292 299 expect(getAffectedRouteCount(logs)).toBe(2); 293 - 300 + 294 301 // Index and about should be rebuilt, blog should not 295 302 const after = recordBuildIds(htmlPaths); 296 303 expect(after.blog).toBe(before.blog); ··· 303 310 // ============================================================ 304 311 test("image change rebuilds only routes using it", async ({ devServer }) => { 305 312 let testCounter = 0; 306 - 313 + 307 314 async function triggerChange(suffix: string) { 308 315 testCounter++; 309 316 devServer.clearLogs(); ··· 311 318 // This simulates modifying an image file 312 319 const modified = Buffer.concat([ 313 320 originals.logoPng as Buffer, 314 - Buffer.from(`<!-- test-${testCounter}-${suffix} -->`) 321 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 315 322 ]); 316 323 writeFileSync(assets.logoPng, modified); 317 324 return await waitForBuildComplete(devServer, 30000); 318 325 } 319 - 326 + 320 327 await setupIncrementalState(devServer, triggerChange); 321 328 322 329 const before = recordBuildIds(htmlPaths); 323 330 expect(before.index).not.toBeNull(); 324 - 325 - await new Promise(resolve => setTimeout(resolve, 500)); 326 - 331 + 332 + await new Promise((resolve) => setTimeout(resolve, 500)); 333 + 327 334 const logs = await triggerChange("final"); 328 - 335 + 329 336 // Verify incremental build with 1 route 330 337 expect(isIncrementalBuild(logs)).toBe(true); 331 338 expect(getAffectedRouteCount(logs)).toBe(1); 332 - 339 + 333 340 // Only index should be rebuilt (uses logo.png) 334 341 const after = recordBuildIds(htmlPaths); 335 342 expect(after.about).toBe(before.about); ··· 342 349 // ============================================================ 343 350 test("multiple file changes rebuild union of affected routes", async ({ devServer }) => { 344 351 let testCounter = 0; 345 - 352 + 346 353 async function triggerChange(suffix: string) { 347 354 testCounter++; 348 355 devServer.clearLogs(); ··· 351 358 writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 352 359 return await waitForBuildComplete(devServer, 30000); 353 360 } 354 - 361 + 355 362 await setupIncrementalState(devServer, triggerChange); 356 363 357 364 const before = recordBuildIds(htmlPaths); 358 365 expect(before.about).not.toBeNull(); 359 366 expect(before.blog).not.toBeNull(); 360 - 361 - await new Promise(resolve => setTimeout(resolve, 500)); 362 - 367 + 368 + await new Promise((resolve) => setTimeout(resolve, 500)); 369 + 363 370 const logs = await triggerChange("final"); 364 - 371 + 365 372 // Verify incremental build with 2 routes (/about and /blog) 366 373 expect(isIncrementalBuild(logs)).toBe(true); 367 374 expect(getAffectedRouteCount(logs)).toBe(2); 368 - 375 + 369 376 // About and blog should be rebuilt, index should not 370 377 const after = recordBuildIds(htmlPaths); 371 378 expect(after.index).toBe(before.index); ··· 376 383 // ============================================================ 377 384 // TEST 7: CSS url() asset dependency (bg.png via blog.css → /blog) 378 385 // ============================================================ 379 - test("CSS url() asset change triggers rebundling and rebuilds affected routes", async ({ devServer }) => { 386 + test("CSS url() asset change triggers rebundling and rebuilds affected routes", async ({ 387 + devServer, 388 + }) => { 380 389 let testCounter = 0; 381 - 390 + 382 391 async function triggerChange(suffix: string) { 383 392 testCounter++; 384 393 devServer.clearLogs(); ··· 386 395 // Changing it should trigger rebundling and rebuild /blog 387 396 const modified = Buffer.concat([ 388 397 originals.bgPng as Buffer, 389 - Buffer.from(`<!-- test-${testCounter}-${suffix} -->`) 398 + Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 390 399 ]); 391 400 writeFileSync(assets.bgPng, modified); 392 401 return await waitForBuildComplete(devServer, 30000); 393 402 } 394 - 403 + 395 404 await setupIncrementalState(devServer, triggerChange); 396 405 397 406 const before = recordBuildIds(htmlPaths); 398 407 expect(before.blog).not.toBeNull(); 399 - 400 - await new Promise(resolve => setTimeout(resolve, 500)); 401 - 408 + 409 + await new Promise((resolve) => setTimeout(resolve, 500)); 410 + 402 411 const logs = await triggerChange("final"); 403 - 412 + 404 413 // Verify incremental build triggered 405 414 expect(isIncrementalBuild(logs)).toBe(true); 406 - 415 + 407 416 // Blog should be rebuilt (uses blog.css which references bg.png via url()) 408 417 // The bundler should have been re-run to update the hashed asset reference 409 418 const after = recordBuildIds(htmlPaths); 410 419 expect(after.blog).not.toBe(before.blog); 420 + }); 421 + 422 + // ============================================================ 423 + // TEST 8: Folder rename handling 424 + // ============================================================ 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"); 430 + 431 + // Clean up any leftover folders from previous test runs 432 + if (existsSync(testFolder)) { 433 + rmSync(testFolder, { recursive: true }); 434 + } 435 + if (existsSync(renamedFolder)) { 436 + rmSync(renamedFolder, { recursive: true }); 437 + } 438 + 439 + 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; }"); 443 + 444 + // Wait for initial detection 445 + devServer.clearLogs(); 446 + await new Promise((resolve) => setTimeout(resolve, 1000)); 447 + 448 + // Step 2: Rename the folder 449 + devServer.clearLogs(); 450 + renameSync(testFolder, renamedFolder); 451 + 452 + // Wait for the file watcher to detect the rename and process it 453 + const startTime = Date.now(); 454 + const timeoutMs = 10000; 455 + let logsAfterRename: string[] = []; 456 + 457 + while (Date.now() - startTime < timeoutMs) { 458 + logsAfterRename = devServer.getLogs(100); 459 + const logsText = logsAfterRename.join("\n"); 460 + 461 + // Wait for the build to complete (indicates paths were processed) 462 + if (logsText.includes("rerun finished") || logsText.includes("Build completed")) { 463 + break; 464 + } 465 + 466 + await new Promise((resolve) => setTimeout(resolve, 100)); 467 + } 468 + 469 + // Log what we received for debugging 470 + console.log("Logs after folder rename:", logsAfterRename.slice(-20)); 471 + 472 + const logsText = logsAfterRename.join("\n"); 473 + 474 + // Verify that events were received 475 + expect(logsText).toContain("Received"); 476 + 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 + } finally { 482 + // Cleanup: remove test folders 483 + if (existsSync(testFolder)) { 484 + rmSync(testFolder, { recursive: true }); 485 + } 486 + if (existsSync(renamedFolder)) { 487 + rmSync(renamedFolder, { recursive: true }); 488 + } 489 + } 411 490 }); 412 491 });
+2 -2
e2e/tests/test-utils.ts
··· 188 188 let hash = 0; 189 189 for (let i = 0; i < fixtureName.length; i++) { 190 190 const char = fixtureName.charCodeAt(i); 191 - hash = ((hash << 5) - hash) + char; 191 + hash = (hash << 5) - hash + char; 192 192 hash = hash & hash; // Convert to 32bit integer 193 193 } 194 194 // Use modulo to keep the offset reasonable (0-99) ··· 237 237 if (!server) { 238 238 // Calculate port based on fixture name hash + worker index to avoid collisions 239 239 const fixtureOffset = getFixturePortOffset(fixtureName); 240 - const preferredPort = basePort + (workerIndex * 100) + fixtureOffset; 240 + const preferredPort = basePort + workerIndex * 100 + fixtureOffset; 241 241 const port = findAvailablePort(preferredPort); 242 242 243 243 server = await startDevServer({
+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/docs/prefetching.md
··· 49 49 50 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 51 52 - ## Possible risks 52 + ## Possible risks 53 53 54 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 55
+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, you will. 74 74 75 75 ```md 76 76 Here's my cool video: