···172172 .flat_map(|e| e.paths.iter().cloned())
173173 .collect();
174174175175- // Expand directory paths to include files inside them
176176- // This is needed because folder renames only report the folder, not contents
177177- changed_paths = expand_directory_paths(changed_paths);
178178-179175 // Deduplicate paths
180176 changed_paths.sort();
181177 changed_paths.dedup();
···299295 }
300296301297 true
302302-}
303303-304304-/// Expand directory paths to include all files within them recursively.
305305-/// This is needed because file watcher events for folder renames only include
306306-/// the folder path, not the files inside.
307307-fn expand_directory_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
308308- let mut expanded = Vec::new();
309309-310310- for path in paths {
311311- if path.is_dir() {
312312- // Recursively collect all files in the directory
313313- collect_files_recursive(&path, &mut expanded);
314314- // Also keep the directory itself for cases where we need to know a dir changed
315315- expanded.push(path);
316316- } else {
317317- expanded.push(path);
318318- }
319319- }
320320-321321- expanded
322322-}
323323-324324-/// Recursively collect all files in a directory.
325325-fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
326326- if let Ok(entries) = fs::read_dir(dir) {
327327- for entry in entries.filter_map(|e| e.ok()) {
328328- let path = entry.path();
329329- if path.is_dir() {
330330- collect_files_recursive(&path, files);
331331- } else if path.is_file() {
332332- files.push(path);
333333- }
334334- }
335335- }
336298}
337299338300async fn shutdown_signal() {
···121121 continue;
122122 }
123123124124- // Only do directory prefix check if the changed path is actually a directory
125125- // This handles cases where a directory is modified and we want to rebuild
126126- // all routes that use assets within that directory
127127- if changed_file.is_dir() {
128128- let canonical_dir = canonical_changed.as_ref().unwrap_or(changed_file);
124124+ // Directory prefix check: find all routes using assets within this directory.
125125+ // This handles two cases:
126126+ // 1. A directory was modified - rebuild all routes using assets in that dir
127127+ // 2. A directory was renamed/deleted - the old path no longer exists but we
128128+ // still need to rebuild routes that used assets under that path
129129+ //
130130+ // We do this check if:
131131+ // - The path currently exists as a directory, OR
132132+ // - The path doesn't exist (could be a deleted/renamed directory)
133133+ let should_check_prefix = changed_file.is_dir() || !changed_file.exists();
134134+135135+ if should_check_prefix {
136136+ // Use original path for prefix matching (canonical won't exist for deleted dirs)
129137 for (asset_path, routes) in &self.asset_to_routes {
130130- if asset_path.starts_with(canonical_dir) {
138138+ if asset_path.starts_with(changed_file) {
131139 affected_routes.extend(routes.iter().cloned());
132140 }
133141 }
···143151 self.bundler_inputs.clear();
144152 }
145153}
154154+155155+#[cfg(test)]
156156+mod tests {
157157+ use super::*;
158158+159159+ fn make_route(path: &str) -> RouteIdentifier {
160160+ RouteIdentifier::base(path.to_string(), None)
161161+ }
162162+163163+ #[test]
164164+ fn test_get_affected_routes_exact_match() {
165165+ let mut state = BuildState::new();
166166+ let asset_path = PathBuf::from("/project/src/assets/logo.png");
167167+ let route = make_route("/");
168168+169169+ state.track_asset(asset_path.clone(), route.clone());
170170+171171+ // Exact match should work
172172+ let affected = state.get_affected_routes(&[asset_path]);
173173+ assert_eq!(affected.len(), 1);
174174+ assert!(affected.contains(&route));
175175+ }
176176+177177+ #[test]
178178+ fn test_get_affected_routes_no_match() {
179179+ let mut state = BuildState::new();
180180+ let asset_path = PathBuf::from("/project/src/assets/logo.png");
181181+ let route = make_route("/");
182182+183183+ state.track_asset(asset_path, route);
184184+185185+ // Different file should not match
186186+ let other_path = PathBuf::from("/project/src/assets/other.png");
187187+ let affected = state.get_affected_routes(&[other_path]);
188188+ assert!(affected.is_empty());
189189+ }
190190+191191+ #[test]
192192+ fn test_get_affected_routes_deleted_directory() {
193193+ let mut state = BuildState::new();
194194+195195+ // Track assets under a directory path
196196+ let asset1 = PathBuf::from("/project/src/assets/icons/logo.png");
197197+ let asset2 = PathBuf::from("/project/src/assets/icons/favicon.ico");
198198+ let asset3 = PathBuf::from("/project/src/assets/styles.css");
199199+ let route1 = make_route("/");
200200+ let route2 = make_route("/about");
201201+202202+ state.track_asset(asset1, route1.clone());
203203+ state.track_asset(asset2, route1.clone());
204204+ state.track_asset(asset3, route2.clone());
205205+206206+ // Simulate a deleted/renamed directory (path doesn't exist)
207207+ // The "icons" directory was renamed, so the old path doesn't exist
208208+ let deleted_dir = PathBuf::from("/project/src/assets/icons");
209209+210210+ // Since the path doesn't exist, it should check prefix matching
211211+ let affected = state.get_affected_routes(&[deleted_dir]);
212212+213213+ // Should find route1 (uses assets under /icons/) but not route2
214214+ assert_eq!(affected.len(), 1);
215215+ assert!(affected.contains(&route1));
216216+ }
217217+218218+ #[test]
219219+ fn test_get_affected_routes_multiple_routes_same_asset() {
220220+ let mut state = BuildState::new();
221221+ let asset_path = PathBuf::from("/project/src/assets/shared.css");
222222+ let route1 = make_route("/");
223223+ let route2 = make_route("/about");
224224+225225+ state.track_asset(asset_path.clone(), route1.clone());
226226+ state.track_asset(asset_path.clone(), route2.clone());
227227+228228+ let affected = state.get_affected_routes(&[asset_path]);
229229+ assert_eq!(affected.len(), 2);
230230+ assert!(affected.contains(&route1));
231231+ assert!(affected.contains(&route2));
232232+ }
233233+}
···88impl Route for Blog {
99 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
1010 let _style = ctx.assets.add_style("src/assets/blog.css");
1111+ let _icon_style = ctx.assets.add_style("src/assets/icons/blog-icon.css");
11121213 // Generate a unique build ID - uses nanoseconds for uniqueness
1314 let build_id = SystemTime::now()
+70-40
e2e/tests/incremental-build.spec.ts
···11import { expect } from "@playwright/test";
22import { createTestWithFixture } from "./test-utils";
33-import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, existsSync } from "node:fs";
33+import { readFileSync, writeFileSync, renameSync, rmSync, existsSync } from "node:fs";
44import { resolve, dirname } from "node:path";
55import { fileURLToPath } from "node:url";
66···5454 await new Promise((resolve) => setTimeout(resolve, 100));
5555 }
56565757+ // On timeout, log what we DID see for debugging
5858+ console.log("TIMEOUT - logs seen:", devServer.getLogs(50));
5759 throw new Error(`Build did not complete within ${timeoutMs}ms`);
5860}
5961···420422 });
421423422424 // ============================================================
423423- // TEST 8: Folder rename handling
425425+ // TEST 8: Folder rename detection
424426 // ============================================================
425425- test("folder rename triggers rebuild with correct changed paths", async ({ devServer }) => {
426426- // Create a test folder structure that we can rename
427427- const testFolder = resolve(fixturePath, "src", "assets", "test-icons");
428428- const renamedFolder = resolve(fixturePath, "src", "assets", "test-icons-renamed");
429429- const testFile = resolve(testFolder, "test-icon.css");
427427+ test("folder rename is detected and affects routes using assets in that folder", async ({ devServer }) => {
428428+ // This test verifies that renaming a folder containing tracked assets
429429+ // is detected by the file watcher and affects the correct routes.
430430+ //
431431+ // Setup: The blog page uses src/assets/icons/blog-icon.css
432432+ // Test: Rename icons -> icons-renamed, verify the blog route is identified as affected
433433+ //
434434+ // Note: The actual build will fail because the asset path becomes invalid,
435435+ // but this test verifies the DETECTION and ROUTE MATCHING works correctly.
436436+437437+ const iconsFolder = resolve(fixturePath, "src", "assets", "icons");
438438+ const renamedFolder = resolve(fixturePath, "src", "assets", "icons-renamed");
439439+ const iconFile = resolve(iconsFolder, "blog-icon.css");
430440431431- // Clean up any leftover folders from previous test runs
432432- if (existsSync(testFolder)) {
433433- rmSync(testFolder, { recursive: true });
434434- }
441441+ // Ensure we start with the correct state
435442 if (existsSync(renamedFolder)) {
436436- rmSync(renamedFolder, { recursive: true });
443443+ // Restore from previous failed run
444444+ renameSync(renamedFolder, iconsFolder);
445445+ await new Promise((resolve) => setTimeout(resolve, 1000));
437446 }
438447448448+ // Make sure the icons folder exists with the file
449449+ expect(existsSync(iconsFolder)).toBe(true);
450450+ expect(existsSync(iconFile)).toBe(true);
451451+439452 try {
440440- // Step 1: Create the test folder and file
441441- mkdirSync(testFolder, { recursive: true });
442442- writeFileSync(testFile, "/* test icon styles */\n.icon { width: 16px; }");
453453+ // First, trigger TWO builds to establish the asset tracking
454454+ // The first build creates the state, the second ensures the icon is tracked
455455+ const originalContent = readFileSync(iconFile, "utf-8");
456456+457457+ // Build 1: Ensure blog-icon.css is used and tracked
458458+ devServer.clearLogs();
459459+ writeFileSync(iconFile, originalContent + "\n/* setup1 */");
460460+ await waitForBuildComplete(devServer, 30000);
461461+ await new Promise((resolve) => setTimeout(resolve, 500));
443462444444- // Wait for initial detection
463463+ // Build 2: Now the asset should definitely be in the state
445464 devServer.clearLogs();
446446- await new Promise((resolve) => setTimeout(resolve, 1000));
465465+ writeFileSync(iconFile, originalContent + "\n/* setup2 */");
466466+ await waitForBuildComplete(devServer, 30000);
467467+ await new Promise((resolve) => setTimeout(resolve, 500));
447468448448- // Step 2: Rename the folder
469469+ // Clear for the actual test
449470 devServer.clearLogs();
450450- renameSync(testFolder, renamedFolder);
471471+472472+ // Rename icons -> icons-renamed
473473+ renameSync(iconsFolder, renamedFolder);
451474452452- // Wait for the file watcher to detect the rename and process it
475475+ // Wait for the build to be attempted (it will fail because path is now invalid)
453476 const startTime = Date.now();
454454- const timeoutMs = 10000;
455455- let logsAfterRename: string[] = [];
477477+ const timeoutMs = 15000;
478478+ let logs: string[] = [];
456479457480 while (Date.now() - startTime < timeoutMs) {
458458- logsAfterRename = devServer.getLogs(100);
459459- const logsText = logsAfterRename.join("\n");
481481+ logs = devServer.getLogs(100);
482482+ const logsText = logs.join("\n");
460483461461- // Wait for the build to complete (indicates paths were processed)
462462- if (logsText.includes("rerun finished") || logsText.includes("Build completed")) {
484484+ // Wait for either success or failure
485485+ if (logsText.includes("finished") || logsText.includes("failed")) {
463486 break;
464487 }
465488466489 await new Promise((resolve) => setTimeout(resolve, 100));
467490 }
468491469469- // Log what we received for debugging
470470- console.log("Logs after folder rename:", logsAfterRename.slice(-20));
492492+ console.log("Logs after folder rename:", logs.slice(-15));
493493+494494+ const logsText = logs.join("\n");
495495+496496+ // Key assertions: verify the detection and route matching worked
497497+ // 1. The folder paths should be in changed files
498498+ expect(logsText).toContain("icons");
471499472472- const logsText = logsAfterRename.join("\n");
500500+ // 2. The blog route should be identified as affected
501501+ expect(logsText).toContain("Rebuilding 1 affected routes");
502502+ expect(logsText).toContain("/blog");
473503474474- // Verify that events were received
475475- expect(logsText).toContain("Received");
504504+ // 3. Other routes should NOT be affected (index and about don't use icons/)
505505+ expect(logsText).not.toContain("/about");
476506477477- // The key assertion: the changed files should include the FILE inside the folder,
478478- // not just the folder path itself
479479- // After expanding directory paths, we should see the CSS file
480480- expect(logsText).toContain("test-icon.css");
481507 } finally {
482482- // Cleanup: remove test folders
483483- if (existsSync(testFolder)) {
484484- rmSync(testFolder, { recursive: true });
508508+ // Restore: rename icons-renamed back to icons
509509+ if (existsSync(renamedFolder) && !existsSync(iconsFolder)) {
510510+ renameSync(renamedFolder, iconsFolder);
485511 }
486486- if (existsSync(renamedFolder)) {
487487- rmSync(renamedFolder, { recursive: true });
512512+ // Restore original content
513513+ if (existsSync(iconFile)) {
514514+ const content = readFileSync(iconFile, "utf-8");
515515+ writeFileSync(iconFile, content.replace(/\n\/\* setup[12] \*\//g, ""));
488516 }
517517+ // Wait for restoration to be processed
518518+ await new Promise((resolve) => setTimeout(resolve, 1000));
489519 }
490520 });
491521});
···214214215215```markdown
216216---
217217-title: { { enhance title="Super Title" / } }
217217+title: {{ enhance title="Super Title" /}}
218218---
219219220220Here's an image with a caption:
+1-1
website/content/news/2026-in-the-cursed-lands.md
···70707171### Shortcodes
72727373-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.
7373+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.
74747575```md
7676Here's my cool video: