Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: lol

+1174 -39
+1
Cargo.lock
··· 1684 1684 dependencies = [ 1685 1685 "maud", 1686 1686 "maudit", 1687 + "serde", 1687 1688 ] 1688 1689 1689 1690 [[package]]
+4 -1
benchmarks/realistic-blog/src/routes/article.rs
··· 16 16 &self, 17 17 ctx: &mut DynamicRouteContext, 18 18 ) -> Pages<ArticlesParams, PaginatedContentPage<ArticleContent>> { 19 - let articles = &ctx.content.get_source::<ArticleContent>("articles").entries; 19 + let articles = ctx 20 + .content 21 + .get_source::<ArticleContent>("articles") 22 + .entries(); 20 23 21 24 let mut articles = articles.to_vec(); 22 25 articles.sort_by(|a, b| b.data(ctx).date.cmp(&a.data(ctx).date));
+3 -4
benchmarks/realistic-blog/src/routes/index.rs
··· 5 5 content::ArticleContent, 6 6 layout::layout, 7 7 routes::{ 8 - Article, Articles, 9 8 article::{ArticleParams, ArticlesParams}, 9 + Article, Articles, 10 10 }, 11 11 }; 12 12 ··· 18 18 let mut articles = ctx 19 19 .content 20 20 .get_source::<ArticleContent>("articles") 21 - .entries 22 - .iter() 23 - .collect::<Vec<_>>(); // Collect into a Vec to allow sorting 21 + .entries() 22 + .to_vec(); // Clone into a Vec to allow sorting 24 23 25 24 // Sort by date, newest first 26 25 articles.sort_by(|a, b| b.data(ctx).date.cmp(&a.data(ctx).date));
+155 -12
crates/maudit/src/build.rs
··· 19 19 options::PrefetchStrategy, 20 20 state::{BuildState, RouteIdentifier}, 21 21 }, 22 - content::ContentSources, 22 + content::{ContentSources, finish_tracking_content_files, start_tracking_content_files}, 23 23 is_dev, 24 24 logging::print_title, 25 25 route::{CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams}, ··· 142 142 build_state.track_source_file(source_path, route_id.clone()); 143 143 } 144 144 145 + /// Helper to track content files accessed during page rendering. 146 + /// Only performs work when incremental builds are enabled and route_id is provided. 147 + /// This should be called after `finish_tracking_content_files()` to get the accessed files. 148 + fn track_route_content_files( 149 + build_state: &mut BuildState, 150 + route_id: Option<&RouteIdentifier>, 151 + accessed_files: Option<FxHashSet<PathBuf>>, 152 + ) { 153 + // Skip tracking entirely when route_id is not provided (incremental disabled) 154 + let Some(route_id) = route_id else { 155 + return; 156 + }; 157 + 158 + // Skip if no files were tracked 159 + let Some(files) = accessed_files else { 160 + return; 161 + }; 162 + 163 + for file_path in files { 164 + build_state.track_content_file(file_path, route_id.clone()); 165 + } 166 + } 167 + 145 168 pub fn execute_build( 146 169 routes: &[&dyn FullRoute], 147 170 content_sources: &mut ContentSources, ··· 177 200 BuildState::new() 178 201 }; 179 202 180 - debug!(target: "build", "Loaded build state with {} asset mappings, {} source mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len()); 203 + debug!(target: "build", "Loaded build state with {} asset mappings, {} source mappings, {} content file mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len(), build_state.content_file_to_routes.len()); 181 204 debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some()); 182 205 183 206 // Determine if this is an incremental build ··· 191 214 info!(target: "build", "Incremental build: {} files changed", changed.len()); 192 215 info!(target: "build", "Changed files: {:?}", changed); 193 216 194 - info!(target: "build", "Build state has {} asset mappings, {} source mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len()); 217 + info!(target: "build", "Build state has {} asset mappings, {} source mappings, {} content file mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len(), build_state.content_file_to_routes.len()); 195 218 196 219 match build_state.get_affected_routes(changed) { 197 220 Some(affected) => { ··· 287 310 288 311 let content_sources_start = Instant::now(); 289 312 print_title("initializing content sources"); 290 - content_sources.sources_mut().iter_mut().for_each(|source| { 291 - let source_start = Instant::now(); 292 - source.init(); 313 + 314 + // Determine which content sources need to be initialized 315 + // For incremental builds, only re-init sources whose files have changed 316 + let sources_to_init: Option<FxHashSet<String>> = if is_incremental { 317 + if let Some(changed) = changed_files { 318 + build_state.get_affected_content_sources(changed) 319 + } else { 320 + None // Full init 321 + } 322 + } else { 323 + None // Full init 324 + }; 325 + 326 + // Initialize content sources (all or selective) 327 + let initialized_sources: Vec<String> = match &sources_to_init { 328 + Some(source_names) if !source_names.is_empty() => { 329 + info!(target: "content", "Selectively initializing {} content source(s): {:?}", source_names.len(), source_names); 330 + 331 + // Clear mappings for sources being re-initialized before init 332 + build_state.clear_content_mappings_for_sources(source_names); 333 + 334 + // Initialize only the affected sources 335 + let mut initialized = Vec::new(); 336 + for source in content_sources.sources_mut() { 337 + if source_names.contains(source.get_name()) { 338 + let source_start = Instant::now(); 339 + source.init(); 340 + info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default())); 341 + initialized.push(source.get_name().to_string()); 342 + } else { 343 + info!(target: "content", "{} (unchanged, skipped)", source.get_name()); 344 + } 345 + } 346 + initialized 347 + } 348 + Some(_) => { 349 + // Empty set means no content files changed, skip all initialization 350 + info!(target: "content", "No content files changed, skipping content source initialization"); 351 + Vec::new() 352 + } 353 + None => { 354 + // Full initialization (first build, unknown files, or non-incremental) 355 + info!(target: "content", "Initializing all content sources"); 356 + 357 + // Clear all content mappings for full init 358 + build_state.clear_content_file_mappings(); 359 + build_state.content_file_to_source.clear(); 360 + 361 + let mut initialized = Vec::new(); 362 + for source in content_sources.sources_mut() { 363 + let source_start = Instant::now(); 364 + source.init(); 365 + info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default())); 366 + initialized.push(source.get_name().to_string()); 367 + } 368 + initialized 369 + } 370 + }; 293 371 294 - info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default())); 295 - }); 372 + // Track file->source mappings for all initialized sources 373 + for source in content_sources.sources() { 374 + if initialized_sources.contains(&source.get_name().to_string()) { 375 + let source_name = source.get_name().to_string(); 376 + for file_path in source.get_entry_file_paths() { 377 + build_state.track_content_file_source(file_path, source_name.clone()); 378 + } 379 + } 380 + } 296 381 297 382 info!(target: "content", "{}", format!("Content sources initialized in {}", format_elapsed_time( 298 383 content_sources_start.elapsed(), 299 384 &FormatElapsedTimeOptions::default(), 300 385 )).bold()); 301 386 387 + // Clear content file->routes mappings for routes being rebuilt 388 + // (so they get fresh tracking during this build) 389 + if let Some(ref routes) = routes_to_rebuild { 390 + build_state.clear_content_file_mappings_for_routes(routes); 391 + } 392 + 302 393 print_title("generating pages"); 303 394 let pages_start = Instant::now(); 304 395 ··· 405 496 let params = PageParams::default(); 406 497 let url = cached_route.url(&params); 407 498 499 + // Start tracking content file access for incremental builds 500 + if options.incremental { 501 + start_tracking_content_files(); 502 + } 503 + 408 504 let result = route.build(&mut PageContext::from_static_route( 409 505 content_sources, 410 506 &mut route_assets, ··· 413 509 None, 414 510 ))?; 415 511 512 + // Finish tracking and record accessed content files 513 + let accessed_files = if options.incremental { 514 + finish_tracking_content_files() 515 + } else { 516 + None 517 + }; 518 + 416 519 let file_path = cached_route.file_path(&params, &options.output_dir); 417 520 418 521 write_route_file(&result, &file_path)?; 419 522 420 523 info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 421 524 422 - // Track assets and source file for this route 525 + // Track assets, source file, and content files for this route 423 526 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 424 527 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 528 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 425 529 426 530 build_pages_images.extend(route_assets.images); 427 531 build_pages_scripts.extend(route_assets.scripts); ··· 482 586 let url = cached_route.url(&page.0); 483 587 let file_path = cached_route.file_path(&page.0, &options.output_dir); 484 588 589 + // Start tracking content file access for incremental builds 590 + if options.incremental { 591 + start_tracking_content_files(); 592 + } 593 + 485 594 let content = route.build(&mut PageContext::from_dynamic_route( 486 595 &page, 487 596 content_sources, ··· 491 600 None, 492 601 ))?; 493 602 603 + // Finish tracking and record accessed content files 604 + let accessed_files = if options.incremental { 605 + finish_tracking_content_files() 606 + } else { 607 + None 608 + }; 609 + 494 610 write_route_file(&content, &file_path)?; 495 611 496 612 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options)); 497 613 498 - // Track assets and source file for this page 614 + // Track assets, source file, and content files for this page 499 615 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 500 616 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 617 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 501 618 502 619 build_metadata.add_page( 503 620 base_path.clone(), ··· 558 675 &variant_id, 559 676 )?; 560 677 678 + // Start tracking content file access for incremental builds 679 + if options.incremental { 680 + start_tracking_content_files(); 681 + } 682 + 561 683 let result = route.build(&mut PageContext::from_static_route( 562 684 content_sources, 563 685 &mut route_assets, ··· 566 688 Some(variant_id.clone()), 567 689 ))?; 568 690 691 + // Finish tracking and record accessed content files 692 + let accessed_files = if options.incremental { 693 + finish_tracking_content_files() 694 + } else { 695 + None 696 + }; 697 + 569 698 write_route_file(&result, &file_path)?; 570 699 571 700 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options)); 572 701 573 - // Track assets and source file for this variant 702 + // Track assets, source file, and content files for this variant 574 703 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 575 704 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 705 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 576 706 577 707 build_pages_images.extend(route_assets.images); 578 708 build_pages_scripts.extend(route_assets.scripts); ··· 640 770 &variant_id, 641 771 )?; 642 772 773 + // Start tracking content file access for incremental builds 774 + if options.incremental { 775 + start_tracking_content_files(); 776 + } 777 + 643 778 let content = route.build(&mut PageContext::from_dynamic_route( 644 779 &page, 645 780 content_sources, ··· 649 784 Some(variant_id.clone()), 650 785 ))?; 651 786 787 + // Finish tracking and record accessed content files 788 + let accessed_files = if options.incremental { 789 + finish_tracking_content_files() 790 + } else { 791 + None 792 + }; 793 + 652 794 write_route_file(&content, &file_path)?; 653 795 654 796 info!(target: "pages", "│ ├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options)); 655 797 656 - // Track assets and source file for this variant page 798 + // Track assets, source file, and content files for this variant page 657 799 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 658 800 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 801 + track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files); 659 802 660 803 build_metadata.add_page( 661 804 variant_path.clone(),
+554
crates/maudit/src/build/state.rs
··· 63 63 /// Value: set of routes defined in this source file 64 64 pub source_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>, 65 65 66 + /// Maps content file paths to routes that use them 67 + /// Key: canonicalized content file path (e.g., content/articles/hello.md) 68 + /// Value: set of routes using this specific content file 69 + /// This provides granular tracking - if only hello.md changes, only routes 70 + /// that accessed hello.md need to be rebuilt. 71 + pub content_file_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>, 72 + 73 + /// Maps content file paths to the content source that owns them 74 + /// Key: canonicalized content file path (e.g., content/articles/hello.md) 75 + /// Value: content source name (e.g., "articles") 76 + /// This allows selective re-initialization of only the content sources 77 + /// whose files have changed. 78 + pub content_file_to_source: FxHashMap<PathBuf, String>, 79 + 66 80 /// Stores all bundler input paths from the last build 67 81 /// This needs to be preserved to ensure consistent bundling 68 82 pub bundler_inputs: Vec<String>, ··· 112 126 .insert(route_id); 113 127 } 114 128 129 + /// Add a content file->route mapping 130 + /// This tracks which specific content files are used by which routes for incremental rebuilds. 131 + /// This provides granular tracking - only routes that actually accessed a specific file 132 + /// will be rebuilt when that file changes. 133 + /// 134 + /// The file path is canonicalized before storage to ensure consistent lookups when 135 + /// comparing against absolute paths from the file watcher. 136 + pub fn track_content_file(&mut self, file_path: PathBuf, route_id: RouteIdentifier) { 137 + // Canonicalize the path to ensure consistent matching with absolute paths from the watcher 138 + let canonical_path = file_path.canonicalize().unwrap_or(file_path); 139 + self.content_file_to_routes 140 + .entry(canonical_path) 141 + .or_default() 142 + .insert(route_id); 143 + } 144 + 145 + /// Add a content file->source mapping 146 + /// This tracks which content source owns each file, allowing selective re-initialization 147 + /// of only the sources whose files have changed. 148 + /// 149 + /// The file path is canonicalized before storage to ensure consistent lookups. 150 + pub fn track_content_file_source(&mut self, file_path: PathBuf, source_name: String) { 151 + let canonical_path = file_path.canonicalize().unwrap_or(file_path); 152 + self.content_file_to_source 153 + .insert(canonical_path, source_name); 154 + } 155 + 156 + /// Get the names of content sources that have files in the changed files list. 157 + /// Returns `None` if any changed content file is not tracked (new file), indicating 158 + /// that all content sources should be re-initialized. 159 + /// 160 + /// Only considers files that look like content files (have common content extensions). 161 + pub fn get_affected_content_sources(&self, changed_files: &[PathBuf]) -> Option<FxHashSet<String>> { 162 + let content_extensions = ["md", "mdx", "yaml", "yml", "json", "toml"]; 163 + let mut affected_sources = FxHashSet::default(); 164 + 165 + for changed_file in changed_files { 166 + // Skip files that don't look like content files 167 + let is_content_file = changed_file 168 + .extension() 169 + .and_then(|ext| ext.to_str()) 170 + .map(|ext| content_extensions.contains(&ext)) 171 + .unwrap_or(false); 172 + 173 + if !is_content_file { 174 + continue; 175 + } 176 + 177 + // Try to find the source for this file 178 + let canonical = changed_file.canonicalize().ok(); 179 + 180 + let source = canonical 181 + .as_ref() 182 + .and_then(|c| self.content_file_to_source.get(c)) 183 + .or_else(|| self.content_file_to_source.get(changed_file)); 184 + 185 + match source { 186 + Some(source_name) => { 187 + affected_sources.insert(source_name.clone()); 188 + } 189 + None => { 190 + // Unknown content file - could be a new file 191 + // Fall back to re-initializing all sources 192 + return None; 193 + } 194 + } 195 + } 196 + 197 + Some(affected_sources) 198 + } 199 + 115 200 /// Get all routes affected by changes to specific files. 116 201 /// 117 202 /// Returns `Some(routes)` if all changed files were found in the mappings, ··· 166 251 file_was_tracked = true; 167 252 } 168 253 254 + // Check if this is a content file with direct file->route tracking 255 + if let Some(canonical) = &canonical_changed 256 + && let Some(routes) = self.content_file_to_routes.get(canonical) 257 + { 258 + affected_routes.extend(routes.iter().cloned()); 259 + file_was_tracked = true; 260 + } 261 + 262 + // Also check with original path for content files 263 + if let Some(routes) = self.content_file_to_routes.get(changed_file) { 264 + affected_routes.extend(routes.iter().cloned()); 265 + file_was_tracked = true; 266 + } 267 + 169 268 // Directory prefix check: find all routes using assets within this directory. 170 269 // This handles two cases: 171 270 // 1. A directory was modified - rebuild all routes using assets in that dir ··· 193 292 file_was_tracked = true; 194 293 } 195 294 } 295 + // Also check content files for directory prefix 296 + for (content_path, routes) in &self.content_file_to_routes { 297 + if content_path.starts_with(changed_file) { 298 + affected_routes.extend(routes.iter().cloned()); 299 + file_was_tracked = true; 300 + } 301 + } 196 302 } 197 303 198 304 // Flag as untracked (triggering full rebuild) if: ··· 237 343 pub fn clear(&mut self) { 238 344 self.asset_to_routes.clear(); 239 345 self.source_to_routes.clear(); 346 + self.content_file_to_routes.clear(); 347 + self.content_file_to_source.clear(); 240 348 self.bundler_inputs.clear(); 241 349 } 350 + 351 + /// Clear the content file to routes mapping. 352 + /// This should be called before re-tracking content files after content sources are re-initialized. 353 + pub fn clear_content_file_mappings(&mut self) { 354 + self.content_file_to_routes.clear(); 355 + } 356 + 357 + /// Clear content file mappings for specific sources. 358 + /// This removes both file->routes and file->source mappings for files owned by the given sources. 359 + /// Called when selectively re-initializing specific content sources. 360 + pub fn clear_content_mappings_for_sources(&mut self, source_names: &FxHashSet<String>) { 361 + // Find all files that belong to the specified sources 362 + let files_to_remove: Vec<PathBuf> = self 363 + .content_file_to_source 364 + .iter() 365 + .filter(|(_, source)| source_names.contains(*source)) 366 + .map(|(path, _)| path.clone()) 367 + .collect(); 368 + 369 + // Remove file->source mappings only 370 + // We DON'T clear file->routes mappings here because: 371 + // 1. Routes not being rebuilt should keep their mappings 372 + // 2. Routes being rebuilt will have their mappings cleared separately 373 + // via clear_content_file_mappings_for_routes() 374 + for file in &files_to_remove { 375 + self.content_file_to_source.remove(file); 376 + } 377 + } 378 + 379 + /// Remove content file mappings for specific routes. 380 + /// This is used during incremental builds to clear only the mappings for routes 381 + /// that will be rebuilt, preserving mappings for routes that won't change. 382 + pub fn clear_content_file_mappings_for_routes(&mut self, routes: &FxHashSet<RouteIdentifier>) { 383 + for routes_set in self.content_file_to_routes.values_mut() { 384 + routes_set.retain(|route| !routes.contains(route)); 385 + } 386 + // Remove any entries that have no routes left 387 + self.content_file_to_routes 388 + .retain(|_, routes_set| !routes_set.is_empty()); 389 + } 390 + 391 + /// Check if a file path is a known content file. 392 + /// This is used to determine if a new file might be a content file. 393 + #[allow(dead_code)] // Used in tests and potentially useful for debugging 394 + pub fn is_known_content_file(&self, file_path: &Path) -> bool { 395 + if self.content_file_to_routes.contains_key(file_path) { 396 + return true; 397 + } 398 + 399 + // Try with canonicalized path 400 + if let Ok(canonical) = file_path.canonicalize() { 401 + return self.content_file_to_routes.contains_key(&canonical); 402 + } 403 + 404 + false 405 + } 242 406 } 243 407 244 408 #[cfg(test)] ··· 564 728 println!("Deleted tracked file triggered full rebuild (safe behavior)"); 565 729 } 566 730 } 731 + } 732 + 733 + #[test] 734 + fn test_track_content_file() { 735 + let mut state = BuildState::new(); 736 + let route = make_route("/"); 737 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 738 + 739 + state.track_content_file(content_file.clone(), route.clone()); 740 + 741 + assert_eq!(state.content_file_to_routes.len(), 1); 742 + assert!(state.content_file_to_routes.contains_key(&content_file)); 743 + assert!(state.content_file_to_routes[&content_file].contains(&route)); 744 + } 745 + 746 + #[test] 747 + fn test_track_content_file_multiple_routes() { 748 + let mut state = BuildState::new(); 749 + let route1 = make_route("/"); 750 + let route2 = make_route("/blog"); 751 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 752 + 753 + state.track_content_file(content_file.clone(), route1.clone()); 754 + state.track_content_file(content_file.clone(), route2.clone()); 755 + 756 + assert_eq!(state.content_file_to_routes.len(), 1); 757 + assert_eq!(state.content_file_to_routes[&content_file].len(), 2); 758 + assert!(state.content_file_to_routes[&content_file].contains(&route1)); 759 + assert!(state.content_file_to_routes[&content_file].contains(&route2)); 760 + } 761 + 762 + #[test] 763 + fn test_track_content_file_multiple_files() { 764 + let mut state = BuildState::new(); 765 + let route = make_route("/"); 766 + let file1 = PathBuf::from("/project/content/articles/hello.md"); 767 + let file2 = PathBuf::from("/project/content/articles/world.md"); 768 + 769 + state.track_content_file(file1.clone(), route.clone()); 770 + state.track_content_file(file2.clone(), route.clone()); 771 + 772 + assert_eq!(state.content_file_to_routes.len(), 2); 773 + assert!(state.content_file_to_routes[&file1].contains(&route)); 774 + assert!(state.content_file_to_routes[&file2].contains(&route)); 775 + } 776 + 777 + #[test] 778 + fn test_clear_also_clears_content_files() { 779 + let mut state = BuildState::new(); 780 + let route = make_route("/"); 781 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 782 + 783 + state.track_content_file(content_file, route); 784 + 785 + assert!(!state.content_file_to_routes.is_empty()); 786 + 787 + state.clear(); 788 + 789 + assert!(state.content_file_to_routes.is_empty()); 790 + } 791 + 792 + #[test] 793 + fn test_get_affected_routes_content_file() { 794 + let mut state = BuildState::new(); 795 + let route1 = make_route("/"); 796 + let route2 = make_route("/blog/[slug]"); 797 + let route3 = make_route("/about"); 798 + 799 + // Track content file -> route mappings directly 800 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 801 + let article2 = PathBuf::from("/project/content/articles/world.md"); 802 + let page1 = PathBuf::from("/project/content/pages/about.md"); 803 + 804 + // Route "/" uses article1 and article2 805 + state.track_content_file(article1.clone(), route1.clone()); 806 + state.track_content_file(article2.clone(), route1.clone()); 807 + // Route "/blog/[slug]" uses only article1 808 + state.track_content_file(article1.clone(), route2.clone()); 809 + // Route "/about" uses page1 810 + state.track_content_file(page1.clone(), route3.clone()); 811 + 812 + // When article1 changes, only routes that used article1 should be affected 813 + let affected = state.get_affected_routes(&[article1]).unwrap(); 814 + assert_eq!(affected.len(), 2); 815 + assert!(affected.contains(&route1)); 816 + assert!(affected.contains(&route2)); 817 + assert!(!affected.contains(&route3)); 818 + 819 + // When article2 changes, only route1 should be affected (granular!) 820 + let affected = state.get_affected_routes(&[article2]).unwrap(); 821 + assert_eq!(affected.len(), 1); 822 + assert!(affected.contains(&route1)); 823 + assert!(!affected.contains(&route2)); 824 + assert!(!affected.contains(&route3)); 825 + 826 + // When page1 changes, only route3 should be affected 827 + let affected = state.get_affected_routes(&[page1]).unwrap(); 828 + assert_eq!(affected.len(), 1); 829 + assert!(affected.contains(&route3)); 830 + assert!(!affected.contains(&route1)); 831 + assert!(!affected.contains(&route2)); 832 + } 833 + 834 + #[test] 835 + fn test_get_affected_routes_content_file_multiple_files_changed() { 836 + let mut state = BuildState::new(); 837 + let route1 = make_route("/"); 838 + let route2 = make_route("/about"); 839 + 840 + // Track content files 841 + let article = PathBuf::from("/project/content/articles/hello.md"); 842 + let page = PathBuf::from("/project/content/pages/about.md"); 843 + 844 + state.track_content_file(article.clone(), route1.clone()); 845 + state.track_content_file(page.clone(), route2.clone()); 846 + 847 + // When both files change, both routes should be affected 848 + let affected = state.get_affected_routes(&[article, page]).unwrap(); 849 + assert_eq!(affected.len(), 2); 850 + assert!(affected.contains(&route1)); 851 + assert!(affected.contains(&route2)); 852 + } 853 + 854 + #[test] 855 + fn test_get_affected_routes_content_file_mixed_with_asset() { 856 + let mut state = BuildState::new(); 857 + let route1 = make_route("/"); 858 + let route2 = make_route("/about"); 859 + 860 + // Track a content file for route1 861 + let article = PathBuf::from("/project/content/articles/hello.md"); 862 + state.track_content_file(article.clone(), route1.clone()); 863 + 864 + // Track an asset used by route2 865 + let style = PathBuf::from("/project/src/styles.css"); 866 + state.track_asset(style.clone(), route2.clone()); 867 + 868 + // When both content file and asset change 869 + let affected = state.get_affected_routes(&[article, style]).unwrap(); 870 + assert_eq!(affected.len(), 2); 871 + assert!(affected.contains(&route1)); 872 + assert!(affected.contains(&route2)); 873 + } 874 + 875 + #[test] 876 + fn test_get_affected_routes_unknown_content_file() { 877 + let mut state = BuildState::new(); 878 + let route = make_route("/"); 879 + 880 + // Track a content file 881 + let article = PathBuf::from("/project/content/articles/hello.md"); 882 + state.track_content_file(article, route); 883 + 884 + // A new/unknown .md file that isn't tracked 885 + // This could be a newly created file 886 + let new_file = PathBuf::from("/project/content/articles/new-post.md"); 887 + 888 + // Should trigger full rebuild since it's an untracked file with extension 889 + let affected = state.get_affected_routes(&[new_file]); 890 + assert!( 891 + affected.is_none(), 892 + "New untracked content file should trigger full rebuild" 893 + ); 894 + } 895 + 896 + #[test] 897 + fn test_is_known_content_file() { 898 + let mut state = BuildState::new(); 899 + let route = make_route("/"); 900 + let content_file = PathBuf::from("/project/content/articles/hello.md"); 901 + 902 + state.track_content_file(content_file.clone(), route); 903 + 904 + assert!(state.is_known_content_file(&content_file)); 905 + assert!(!state.is_known_content_file(Path::new("/project/content/articles/unknown.md"))); 906 + } 907 + 908 + #[test] 909 + fn test_content_file_directory_prefix() { 910 + let mut state = BuildState::new(); 911 + let route = make_route("/"); 912 + 913 + // Track content files under a directory 914 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 915 + let article2 = PathBuf::from("/project/content/articles/world.md"); 916 + state.track_content_file(article1, route.clone()); 917 + state.track_content_file(article2, route.clone()); 918 + 919 + // When the parent directory changes (e.g., renamed), should find affected routes 920 + let content_dir = PathBuf::from("/project/content/articles"); 921 + let affected = state.get_affected_routes(&[content_dir]).unwrap(); 922 + assert_eq!(affected.len(), 1); 923 + assert!(affected.contains(&route)); 924 + } 925 + 926 + #[test] 927 + fn test_clear_content_file_mappings_for_routes() { 928 + let mut state = BuildState::new(); 929 + let route1 = make_route("/articles"); 930 + let route2 = make_route("/articles/[slug]"); 931 + let route3 = make_route("/about"); 932 + 933 + // Article 1 is accessed by routes 1 and 2 934 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 935 + state.track_content_file(article1.clone(), route1.clone()); 936 + state.track_content_file(article1.clone(), route2.clone()); 937 + 938 + // Article 2 is accessed by routes 1 and 2 939 + let article2 = PathBuf::from("/project/content/articles/world.md"); 940 + state.track_content_file(article2.clone(), route1.clone()); 941 + state.track_content_file(article2.clone(), route2.clone()); 942 + 943 + // Route 3 uses a different file 944 + let page = PathBuf::from("/project/content/pages/about.md"); 945 + state.track_content_file(page.clone(), route3.clone()); 946 + 947 + assert_eq!(state.content_file_to_routes.len(), 3); 948 + 949 + // Clear mappings only for route2 950 + let mut routes_to_clear = FxHashSet::default(); 951 + routes_to_clear.insert(route2.clone()); 952 + state.clear_content_file_mappings_for_routes(&routes_to_clear); 953 + 954 + // route2 should be removed from article1 and article2 mappings 955 + assert!(!state.content_file_to_routes[&article1].contains(&route2)); 956 + assert!(state.content_file_to_routes[&article1].contains(&route1)); 957 + 958 + assert!(!state.content_file_to_routes[&article2].contains(&route2)); 959 + assert!(state.content_file_to_routes[&article2].contains(&route1)); 960 + 961 + // route3's mapping should be unaffected 962 + assert!(state.content_file_to_routes[&page].contains(&route3)); 963 + } 964 + 965 + #[test] 966 + fn test_clear_content_file_mappings_for_routes_removes_empty_entries() { 967 + let mut state = BuildState::new(); 968 + let route1 = make_route("/articles/first"); 969 + let route2 = make_route("/articles/second"); 970 + 971 + // Route1 uses only article1 972 + let article1 = PathBuf::from("/project/content/articles/first.md"); 973 + state.track_content_file(article1.clone(), route1.clone()); 974 + 975 + // Route2 uses only article2 976 + let article2 = PathBuf::from("/project/content/articles/second.md"); 977 + state.track_content_file(article2.clone(), route2.clone()); 978 + 979 + assert_eq!(state.content_file_to_routes.len(), 2); 980 + 981 + // Clear mappings for route1 982 + let mut routes_to_clear = FxHashSet::default(); 983 + routes_to_clear.insert(route1); 984 + state.clear_content_file_mappings_for_routes(&routes_to_clear); 985 + 986 + // article1 entry should be completely removed (no routes left) 987 + assert!(!state.content_file_to_routes.contains_key(&article1)); 988 + 989 + // article2 entry should still exist 990 + assert!(state.content_file_to_routes.contains_key(&article2)); 991 + assert!(state.content_file_to_routes[&article2].contains(&route2)); 992 + } 993 + 994 + #[test] 995 + fn test_track_content_file_source() { 996 + let mut state = BuildState::new(); 997 + let file = PathBuf::from("/project/content/articles/hello.md"); 998 + 999 + state.track_content_file_source(file.clone(), "articles".to_string()); 1000 + 1001 + assert_eq!(state.content_file_to_source.len(), 1); 1002 + assert_eq!(state.content_file_to_source.get(&file), Some(&"articles".to_string())); 1003 + } 1004 + 1005 + #[test] 1006 + fn test_get_affected_content_sources_single_source() { 1007 + let mut state = BuildState::new(); 1008 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 1009 + let article2 = PathBuf::from("/project/content/articles/world.md"); 1010 + 1011 + state.track_content_file_source(article1.clone(), "articles".to_string()); 1012 + state.track_content_file_source(article2.clone(), "articles".to_string()); 1013 + 1014 + // Change one article file 1015 + let affected = state.get_affected_content_sources(&[article1]).unwrap(); 1016 + assert_eq!(affected.len(), 1); 1017 + assert!(affected.contains("articles")); 1018 + } 1019 + 1020 + #[test] 1021 + fn test_get_affected_content_sources_multiple_sources() { 1022 + let mut state = BuildState::new(); 1023 + let article = PathBuf::from("/project/content/articles/hello.md"); 1024 + let page = PathBuf::from("/project/content/pages/about.md"); 1025 + 1026 + state.track_content_file_source(article.clone(), "articles".to_string()); 1027 + state.track_content_file_source(page.clone(), "pages".to_string()); 1028 + 1029 + // Change both files 1030 + let affected = state.get_affected_content_sources(&[article, page]).unwrap(); 1031 + assert_eq!(affected.len(), 2); 1032 + assert!(affected.contains("articles")); 1033 + assert!(affected.contains("pages")); 1034 + } 1035 + 1036 + #[test] 1037 + fn test_get_affected_content_sources_unknown_file_returns_none() { 1038 + let mut state = BuildState::new(); 1039 + let article = PathBuf::from("/project/content/articles/hello.md"); 1040 + state.track_content_file_source(article, "articles".to_string()); 1041 + 1042 + // A new file that's not tracked 1043 + let new_file = PathBuf::from("/project/content/articles/new-post.md"); 1044 + 1045 + // Should return None (need to re-init all sources) 1046 + let affected = state.get_affected_content_sources(&[new_file]); 1047 + assert!(affected.is_none()); 1048 + } 1049 + 1050 + #[test] 1051 + fn test_get_affected_content_sources_ignores_non_content_files() { 1052 + let mut state = BuildState::new(); 1053 + let article = PathBuf::from("/project/content/articles/hello.md"); 1054 + state.track_content_file_source(article.clone(), "articles".to_string()); 1055 + 1056 + // A non-content file (e.g., .rs file) - should be ignored 1057 + let rust_file = PathBuf::from("/project/src/pages/index.rs"); 1058 + 1059 + // Should return empty set (no content sources affected) 1060 + let affected = state.get_affected_content_sources(&[rust_file.clone()]).unwrap(); 1061 + assert!(affected.is_empty()); 1062 + 1063 + // Mixed: content file + non-content file 1064 + let affected = state.get_affected_content_sources(&[article, rust_file]).unwrap(); 1065 + assert_eq!(affected.len(), 1); 1066 + assert!(affected.contains("articles")); 1067 + } 1068 + 1069 + #[test] 1070 + fn test_clear_content_mappings_for_sources() { 1071 + let mut state = BuildState::new(); 1072 + let route1 = make_route("/articles"); 1073 + let route2 = make_route("/pages"); 1074 + 1075 + // Set up articles source 1076 + let article1 = PathBuf::from("/project/content/articles/hello.md"); 1077 + let article2 = PathBuf::from("/project/content/articles/world.md"); 1078 + state.track_content_file_source(article1.clone(), "articles".to_string()); 1079 + state.track_content_file_source(article2.clone(), "articles".to_string()); 1080 + state.track_content_file(article1.clone(), route1.clone()); 1081 + state.track_content_file(article2.clone(), route1.clone()); 1082 + 1083 + // Set up pages source 1084 + let page = PathBuf::from("/project/content/pages/about.md"); 1085 + state.track_content_file_source(page.clone(), "pages".to_string()); 1086 + state.track_content_file(page.clone(), route2.clone()); 1087 + 1088 + assert_eq!(state.content_file_to_source.len(), 3); 1089 + assert_eq!(state.content_file_to_routes.len(), 3); 1090 + 1091 + // Clear only the articles source 1092 + let mut sources_to_clear = FxHashSet::default(); 1093 + sources_to_clear.insert("articles".to_string()); 1094 + state.clear_content_mappings_for_sources(&sources_to_clear); 1095 + 1096 + // Articles source mappings should be removed 1097 + assert!(!state.content_file_to_source.contains_key(&article1)); 1098 + assert!(!state.content_file_to_source.contains_key(&article2)); 1099 + 1100 + // But routes mappings should be preserved (cleared separately per-route) 1101 + assert!(state.content_file_to_routes.contains_key(&article1)); 1102 + assert!(state.content_file_to_routes.contains_key(&article2)); 1103 + 1104 + // Pages should remain completely unchanged 1105 + assert!(state.content_file_to_source.contains_key(&page)); 1106 + assert!(state.content_file_to_routes.contains_key(&page)); 1107 + assert_eq!(state.content_file_to_source.get(&page), Some(&"pages".to_string())); 1108 + } 1109 + 1110 + #[test] 1111 + fn test_clear_also_clears_content_file_to_source() { 1112 + let mut state = BuildState::new(); 1113 + let file = PathBuf::from("/project/content/articles/hello.md"); 1114 + state.track_content_file_source(file, "articles".to_string()); 1115 + 1116 + assert!(!state.content_file_to_source.is_empty()); 1117 + 1118 + state.clear(); 1119 + 1120 + assert!(state.content_file_to_source.is_empty()); 567 1121 } 568 1122 }
+104 -6
crates/maudit/src/content.rs
··· 1 1 //! Core functions and structs to define the content sources of your website. 2 2 //! 3 3 //! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded. 4 - use std::{any::Any, path::PathBuf, sync::Arc}; 4 + use std::{any::Any, cell::RefCell, path::PathBuf, sync::Arc}; 5 5 6 - use rustc_hash::FxHashMap; 6 + use rustc_hash::{FxHashMap, FxHashSet}; 7 7 8 8 mod highlight; 9 9 pub mod markdown; ··· 25 25 }; 26 26 27 27 pub use highlight::{HighlightOptions, highlight_code}; 28 + 29 + // Thread-local storage for tracking content file access during page rendering. 30 + // This allows us to transparently track which content files a page uses 31 + // without requiring changes to user code. 32 + thread_local! { 33 + static ACCESSED_CONTENT_FILES: RefCell<Option<FxHashSet<PathBuf>>> = const { RefCell::new(None) }; 34 + } 35 + 36 + /// Start tracking content file access for a page render. 37 + /// Call this before rendering a page, then call `finish_tracking_content_files()` 38 + /// after rendering to get the set of accessed content files. 39 + pub(crate) fn start_tracking_content_files() { 40 + ACCESSED_CONTENT_FILES.with(|cell| { 41 + *cell.borrow_mut() = Some(FxHashSet::default()); 42 + }); 43 + } 44 + 45 + /// Finish tracking content file access and return the set of accessed files. 46 + /// Returns `None` if tracking was not started. 47 + pub(crate) fn finish_tracking_content_files() -> Option<FxHashSet<PathBuf>> { 48 + ACCESSED_CONTENT_FILES.with(|cell| cell.borrow_mut().take()) 49 + } 50 + 51 + /// Record that a content file was accessed. 52 + /// This is called internally when entries are accessed. 53 + fn track_content_file_access(file_path: &PathBuf) { 54 + ACCESSED_CONTENT_FILES.with(|cell| { 55 + if let Some(ref mut set) = *cell.borrow_mut() { 56 + set.insert(file_path.clone()); 57 + } 58 + }); 59 + } 28 60 29 61 /// Helps implement a struct as a Markdown content entry. 30 62 /// ··· 302 334 } 303 335 } 304 336 337 + /// Initialize only the content sources with the given names. 338 + /// Sources not in the set are left untouched (their entries remain as-is). 339 + /// Returns the names of sources that were actually initialized. 340 + pub fn init_sources(&mut self, source_names: &rustc_hash::FxHashSet<String>) -> Vec<String> { 341 + let mut initialized = Vec::new(); 342 + for source in &mut self.0 { 343 + if source_names.contains(source.get_name()) { 344 + source.init(); 345 + initialized.push(source.get_name().to_string()); 346 + } 347 + } 348 + initialized 349 + } 350 + 305 351 pub fn get_untyped_source(&self, name: &str) -> &ContentSource<Untyped> { 306 352 self.get_source::<Untyped>(name) 307 353 } ··· 337 383 /// A source of content such as articles, blog posts, etc. 338 384 pub struct ContentSource<T = Untyped> { 339 385 pub name: String, 340 - pub entries: Vec<Arc<EntryInner<T>>>, 386 + entries: Vec<Arc<EntryInner<T>>>, 341 387 pub(crate) init_method: ContentSourceInitMethod<T>, 342 388 } 343 389 ··· 354 400 } 355 401 356 402 pub fn get_entry(&self, id: &str) -> &Entry<T> { 357 - self.entries 403 + let entry = self 404 + .entries 358 405 .iter() 359 406 .find(|entry| entry.id == id) 360 - .unwrap_or_else(|| panic!("Entry with id '{}' not found", id)) 407 + .unwrap_or_else(|| panic!("Entry with id '{}' not found", id)); 408 + 409 + // Track file access for incremental builds 410 + if let Some(ref file_path) = entry.file_path { 411 + track_content_file_access(file_path); 412 + } 413 + 414 + entry 361 415 } 362 416 363 417 pub fn get_entry_safe(&self, id: &str) -> Option<&Entry<T>> { 364 - self.entries.iter().find(|entry| entry.id == id) 418 + let entry = self.entries.iter().find(|entry| entry.id == id); 419 + 420 + // Track file access for incremental builds 421 + if let Some(entry) = &entry 422 + && let Some(ref file_path) = entry.file_path { 423 + track_content_file_access(file_path); 424 + } 425 + 426 + entry 365 427 } 366 428 367 429 pub fn into_params<P>(&self, cb: impl FnMut(&Entry<T>) -> P) -> Vec<P> 368 430 where 369 431 P: Into<PageParams>, 370 432 { 433 + // Track all entries accessed for incremental builds 434 + for entry in &self.entries { 435 + if let Some(ref file_path) = entry.file_path { 436 + track_content_file_access(file_path); 437 + } 438 + } 371 439 self.entries.iter().map(cb).collect() 372 440 } 373 441 ··· 378 446 where 379 447 Params: Into<PageParams>, 380 448 { 449 + // Track all entries accessed for incremental builds 450 + for entry in &self.entries { 451 + if let Some(ref file_path) = entry.file_path { 452 + track_content_file_access(file_path); 453 + } 454 + } 381 455 self.entries.iter().map(cb).collect() 382 456 } 457 + 458 + /// Get all entries, tracking access for incremental builds. 459 + /// 460 + /// This returns a slice of all entries in the content source. 461 + /// You can use standard slice methods like `.iter()`, `.len()`, `.is_empty()`, etc. 462 + pub fn entries(&self) -> &[Entry<T>] { 463 + // Track all entries accessed for incremental builds 464 + for entry in &self.entries { 465 + if let Some(ref file_path) = entry.file_path { 466 + track_content_file_access(file_path); 467 + } 468 + } 469 + &self.entries 470 + } 383 471 } 384 472 385 473 #[doc(hidden)] ··· 389 477 fn init(&mut self); 390 478 fn get_name(&self) -> &str; 391 479 fn as_any(&self) -> &dyn Any; // Used for type checking at runtime 480 + 481 + /// Get all file paths for entries in this content source. 482 + /// Used for incremental builds to map content files to their source. 483 + fn get_entry_file_paths(&self) -> Vec<PathBuf>; 392 484 } 393 485 394 486 impl<T: 'static + Sync + Send> ContentSourceInternal for ContentSource<T> { ··· 400 492 } 401 493 fn as_any(&self) -> &dyn Any { 402 494 self 495 + } 496 + fn get_entry_file_paths(&self) -> Vec<PathBuf> { 497 + self.entries 498 + .iter() 499 + .filter_map(|entry| entry.file_path.clone()) 500 + .collect() 403 501 } 404 502 }
+1 -1
crates/maudit/src/route.rs
··· 282 282 /// impl Route for Index { 283 283 /// fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 284 284 /// let logo = ctx.assets.add_image("logo.png")?; 285 - /// let last_entries = &ctx.content.get_source::<ArticleContent>("articles").entries; 285 + /// let last_entries = ctx.content.get_source::<ArticleContent>("articles").entries(); 286 286 /// 287 287 /// Ok(html! { 288 288 /// main {
+3 -3
crates/oubli/src/archetypes/blog.rs
··· 1 1 //! Blog archetype. 2 2 //! Represents a markdown blog archetype, with an index page and individual entry pages. 3 3 use crate::layouts::layout; 4 - use maud::{Markup, html}; 4 + use maud::{html, Markup}; 5 5 use maudit::content::markdown_entry; 6 - use maudit::route::FullRoute; 7 6 use maudit::route::prelude::*; 7 + use maudit::route::FullRoute; 8 8 9 9 pub fn blog_index_content<T: FullRoute>( 10 10 route: impl FullRoute, ··· 18 18 19 19 let markup = html! { 20 20 main { 21 - @for entry in &blog_entries.entries { 21 + @for entry in blog_entries.entries() { 22 22 a href=(route.url(&BlogEntryParams { entry: entry.id.clone() }.into())) { 23 23 h2 { (entry.data(ctx).title) } 24 24 p { (entry.data(ctx).description) }
+1
e2e/fixtures/incremental-build/Cargo.toml
··· 7 7 [dependencies] 8 8 maudit.workspace = true 9 9 maud.workspace = true 10 + serde.workspace = true
+8
e2e/fixtures/incremental-build/content/articles/first-post.md
··· 1 + --- 2 + title: "First Post" 3 + description: "This is the first post" 4 + --- 5 + 6 + # First Post 7 + 8 + This is the content of the first post.
+8
e2e/fixtures/incremental-build/content/articles/second-post.md
··· 1 + --- 2 + title: "Second Post" 3 + description: "This is the second post" 4 + --- 5 + 6 + # Second Post 7 + 8 + This is the content of the second post.
+8
e2e/fixtures/incremental-build/content/articles/third-post.md
··· 1 + --- 2 + title: "Third Post" 3 + description: "This is the third post" 4 + --- 5 + 6 + # Third Post 7 + 8 + This is the content of the third post.
+12
e2e/fixtures/incremental-build/src/content.rs
··· 1 + use maudit::content::{glob_markdown, markdown_entry}; 2 + 3 + #[markdown_entry] 4 + #[derive(Debug, Clone)] 5 + pub struct ArticleContent { 6 + pub title: String, 7 + pub description: String, 8 + } 9 + 10 + pub fn load_articles() -> Vec<maudit::content::Entry<ArticleContent>> { 11 + glob_markdown("content/articles/*.md") 12 + }
+11 -2
e2e/fixtures/incremental-build/src/main.rs
··· 1 1 use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 2 3 + mod content; 3 4 mod pages; 4 5 5 6 fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 6 7 coronate( 7 - routes![pages::index::Index, pages::about::About, pages::blog::Blog], 8 - content_sources![], 8 + routes![ 9 + pages::index::Index, 10 + pages::about::About, 11 + pages::blog::Blog, 12 + pages::articles::Articles, 13 + pages::article::Article 14 + ], 15 + content_sources![ 16 + "articles" => content::load_articles() 17 + ], 9 18 BuildOptions::default(), 10 19 ) 11 20 }
+56
e2e/fixtures/incremental-build/src/pages/article.rs
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + use crate::content::ArticleContent; 6 + 7 + /// Dynamic route for individual articles - uses `get_entry()` which tracks only the accessed file 8 + #[route("/articles/[slug]")] 9 + pub struct Article; 10 + 11 + #[derive(Params, Clone)] 12 + pub struct ArticleParams { 13 + slug: String, 14 + } 15 + 16 + impl Route<ArticleParams> for Article { 17 + fn pages(&self, ctx: &mut DynamicRouteContext) -> Pages<ArticleParams> { 18 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 19 + 20 + // into_pages tracks all files (for generating the list of pages) 21 + articles.into_pages(|entry| { 22 + Page::from_params(ArticleParams { 23 + slug: entry.id.clone(), 24 + }) 25 + }) 26 + } 27 + 28 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 29 + let slug = ctx.params::<ArticleParams>().slug.clone(); 30 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 31 + 32 + // get_entry tracks only THIS specific file 33 + let article = articles.get_entry(&slug); 34 + 35 + // Generate a unique build ID - uses nanoseconds for uniqueness 36 + let build_id = SystemTime::now() 37 + .duration_since(UNIX_EPOCH) 38 + .map(|d| d.as_nanos().to_string()) 39 + .unwrap_or_else(|_| "0".to_string()); 40 + 41 + html! { 42 + html { 43 + head { 44 + title { (article.data(ctx).title) } 45 + } 46 + body data-build-id=(build_id) { 47 + h1 id="title" { (article.data(ctx).title) } 48 + p id="description" { (article.data(ctx).description) } 49 + div id="content" { 50 + (maud::PreEscaped(article.render(ctx))) 51 + } 52 + } 53 + } 54 + } 55 + } 56 + }
+46
e2e/fixtures/incremental-build/src/pages/articles.rs
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + use std::time::{SystemTime, UNIX_EPOCH}; 4 + 5 + use crate::content::ArticleContent; 6 + 7 + /// Route that lists all articles - uses `entries()` which tracks all content files 8 + #[route("/articles")] 9 + pub struct Articles; 10 + 11 + impl Route for Articles { 12 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 13 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 14 + 15 + // Using entries() tracks ALL content files in the source 16 + let article_list: Vec<_> = articles.entries().iter().collect(); 17 + 18 + // Generate a unique build ID - uses nanoseconds for uniqueness 19 + let build_id = SystemTime::now() 20 + .duration_since(UNIX_EPOCH) 21 + .map(|d| d.as_nanos().to_string()) 22 + .unwrap_or_else(|_| "0".to_string()); 23 + 24 + html! { 25 + html { 26 + head { 27 + title { "Articles" } 28 + } 29 + body data-build-id=(build_id) { 30 + h1 id="title" { "Articles" } 31 + ul id="article-list" { 32 + @for article in article_list { 33 + li { 34 + a href=(format!("/articles/{}", article.id)) { 35 + (article.data(ctx).title) 36 + } 37 + " - " 38 + (article.data(ctx).description) 39 + } 40 + } 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+2
e2e/fixtures/incremental-build/src/pages/mod.rs
··· 1 1 pub mod about; 2 + pub mod article; 3 + pub mod articles; 2 4 pub mod blog; 3 5 pub mod helpers; 4 6 pub mod index;
+187
e2e/tests/incremental-build.spec.ts
··· 208 208 bgPng: resolve(fixturePath, "src", "assets", "bg.png"), 209 209 }; 210 210 211 + // Content file paths (for granular content tracking tests) 212 + const contentFiles = { 213 + firstPost: resolve(fixturePath, "content", "articles", "first-post.md"), 214 + secondPost: resolve(fixturePath, "content", "articles", "second-post.md"), 215 + thirdPost: resolve(fixturePath, "content", "articles", "third-post.md"), 216 + }; 217 + 211 218 // Output HTML paths 212 219 const htmlPaths = { 213 220 index: resolve(fixturePath, "dist", "index.html"), 214 221 about: resolve(fixturePath, "dist", "about", "index.html"), 215 222 blog: resolve(fixturePath, "dist", "blog", "index.html"), 223 + articles: resolve(fixturePath, "dist", "articles", "index.html"), 224 + articleFirst: resolve(fixturePath, "dist", "articles", "first-post", "index.html"), 225 + articleSecond: resolve(fixturePath, "dist", "articles", "second-post", "index.html"), 226 + articleThird: resolve(fixturePath, "dist", "articles", "third-post", "index.html"), 216 227 }; 217 228 218 229 // Original content storage ··· 228 239 originals.logoPng = readFileSync(assets.logoPng); // binary 229 240 originals.teamPng = readFileSync(assets.teamPng); // binary 230 241 originals.bgPng = readFileSync(assets.bgPng); // binary 242 + // Content files 243 + originals.firstPost = readFileSync(contentFiles.firstPost, "utf-8"); 244 + originals.secondPost = readFileSync(contentFiles.secondPost, "utf-8"); 245 + originals.thirdPost = readFileSync(contentFiles.thirdPost, "utf-8"); 231 246 }); 232 247 233 248 test.afterAll(async () => { ··· 240 255 writeFileSync(assets.logoPng, originals.logoPng); 241 256 writeFileSync(assets.teamPng, originals.teamPng); 242 257 writeFileSync(assets.bgPng, originals.bgPng); 258 + // Restore content files 259 + writeFileSync(contentFiles.firstPost, originals.firstPost); 260 + writeFileSync(contentFiles.secondPost, originals.secondPost); 261 + writeFileSync(contentFiles.thirdPost, originals.thirdPost); 243 262 }); 244 263 245 264 // ============================================================ ··· 740 759 // Restoration build may not always complete, that's ok 741 760 } 742 761 } 762 + }); 763 + 764 + // ============================================================ 765 + // TEST 12: Content file change rebuilds only routes accessing that specific file 766 + // ============================================================ 767 + test("content file change rebuilds only routes accessing that file (granular tracking)", async ({ devServer }) => { 768 + // This test verifies granular content file tracking. 769 + // 770 + // Setup: 771 + // - /articles/first-post uses get_entry("first-post") → tracks only first-post.md 772 + // - /articles/second-post uses get_entry("second-post") → tracks only second-post.md 773 + // - /articles (list) uses entries() → tracks ALL content files 774 + // 775 + // When we change first-post.md: 776 + // - /articles/first-post should be rebuilt (directly uses this file) 777 + // - /articles should be rebuilt (uses entries() which tracks all files) 778 + // - /articles/second-post should NOT be rebuilt (uses different file) 779 + // - /articles/third-post should NOT be rebuilt (uses different file) 780 + // - Other routes (/, /about, /blog) should NOT be rebuilt 781 + 782 + let testCounter = 0; 783 + 784 + function modifyFile(suffix: string) { 785 + testCounter++; 786 + const newContent = (originals.firstPost as string).replace( 787 + "first post", 788 + `first post - test-${testCounter}-${suffix}` 789 + ); 790 + writeFileSync(contentFiles.firstPost, newContent); 791 + } 792 + 793 + // Setup: establish incremental state 794 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleFirst"); 795 + expect(before.articleFirst).not.toBeNull(); 796 + expect(before.articleSecond).not.toBeNull(); 797 + expect(before.articles).not.toBeNull(); 798 + 799 + // Trigger the final change 800 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 801 + await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst); 802 + 803 + // Verify incremental build occurred 804 + expect(isIncrementalBuild(logs)).toBe(true); 805 + 806 + // Check which routes were rebuilt 807 + const after = recordBuildIds(htmlPaths); 808 + 809 + // Routes that should NOT be rebuilt (don't access first-post.md) 810 + expect(after.index).toBe(before.index); 811 + expect(after.about).toBe(before.about); 812 + expect(after.blog).toBe(before.blog); 813 + expect(after.articleSecond).toBe(before.articleSecond); 814 + expect(after.articleThird).toBe(before.articleThird); 815 + 816 + // Routes that SHOULD be rebuilt (access first-post.md) 817 + expect(after.articleFirst).not.toBe(before.articleFirst); 818 + expect(after.articles).not.toBe(before.articles); // Uses entries() which tracks all files 819 + }); 820 + 821 + // ============================================================ 822 + // TEST 13: Different content file changes rebuild different routes 823 + // ============================================================ 824 + test("different content files trigger rebuilds of different routes", async ({ devServer }) => { 825 + // This test verifies that changing different content files rebuilds 826 + // different sets of routes, proving granular tracking works. 827 + // 828 + // Change second-post.md: 829 + // - /articles/second-post should be rebuilt 830 + // - /articles (list) should be rebuilt (entries() tracks all) 831 + // - /articles/first-post and /articles/third-post should NOT be rebuilt 832 + 833 + let testCounter = 0; 834 + 835 + function modifyFile(suffix: string) { 836 + testCounter++; 837 + const newContent = (originals.secondPost as string).replace( 838 + "second post", 839 + `second post - test-${testCounter}-${suffix}` 840 + ); 841 + writeFileSync(contentFiles.secondPost, newContent); 842 + } 843 + 844 + // Setup: establish incremental state 845 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleSecond"); 846 + expect(before.articleFirst).not.toBeNull(); 847 + expect(before.articleSecond).not.toBeNull(); 848 + expect(before.articleThird).not.toBeNull(); 849 + expect(before.articles).not.toBeNull(); 850 + 851 + // Trigger the final change 852 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 853 + await waitForBuildIdChange(htmlPaths.articleSecond, before.articleSecond); 854 + 855 + // Verify incremental build occurred 856 + expect(isIncrementalBuild(logs)).toBe(true); 857 + 858 + // Check which routes were rebuilt 859 + const after = recordBuildIds(htmlPaths); 860 + 861 + // Routes that should NOT be rebuilt 862 + expect(after.index).toBe(before.index); 863 + expect(after.about).toBe(before.about); 864 + expect(after.blog).toBe(before.blog); 865 + expect(after.articleFirst).toBe(before.articleFirst); 866 + expect(after.articleThird).toBe(before.articleThird); 867 + 868 + // Routes that SHOULD be rebuilt 869 + expect(after.articleSecond).not.toBe(before.articleSecond); 870 + expect(after.articles).not.toBe(before.articles); 871 + }); 872 + 873 + // ============================================================ 874 + // TEST 14: Multiple content files changed rebuilds union of affected routes 875 + // ============================================================ 876 + test("multiple content file changes rebuild union of affected routes", async ({ devServer }) => { 877 + // This test verifies that changing multiple content files correctly 878 + // rebuilds the union of all routes that access any of the changed files. 879 + // 880 + // Change both first-post.md and third-post.md simultaneously: 881 + // - /articles/first-post should be rebuilt 882 + // - /articles/third-post should be rebuilt 883 + // - /articles (list) should be rebuilt 884 + // - /articles/second-post should NOT be rebuilt 885 + 886 + let testCounter = 0; 887 + 888 + function modifyFile(suffix: string) { 889 + testCounter++; 890 + // Change both first and third posts 891 + const newFirst = (originals.firstPost as string).replace( 892 + "first post", 893 + `first post - multi-${testCounter}-${suffix}` 894 + ); 895 + const newThird = (originals.thirdPost as string).replace( 896 + "third post", 897 + `third post - multi-${testCounter}-${suffix}` 898 + ); 899 + writeFileSync(contentFiles.firstPost, newFirst); 900 + writeFileSync(contentFiles.thirdPost, newThird); 901 + } 902 + 903 + // Setup: establish incremental state 904 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "articleFirst"); 905 + expect(before.articleFirst).not.toBeNull(); 906 + expect(before.articleSecond).not.toBeNull(); 907 + expect(before.articleThird).not.toBeNull(); 908 + expect(before.articles).not.toBeNull(); 909 + 910 + // Trigger the final change 911 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 912 + await waitForBuildIdChange(htmlPaths.articleFirst, before.articleFirst); 913 + 914 + // Verify incremental build occurred 915 + expect(isIncrementalBuild(logs)).toBe(true); 916 + 917 + // Check which routes were rebuilt 918 + const after = recordBuildIds(htmlPaths); 919 + 920 + // Routes that should NOT be rebuilt 921 + expect(after.index).toBe(before.index); 922 + expect(after.about).toBe(before.about); 923 + expect(after.blog).toBe(before.blog); 924 + expect(after.articleSecond).toBe(before.articleSecond); 925 + 926 + // Routes that SHOULD be rebuilt 927 + expect(after.articleFirst).not.toBe(before.articleFirst); 928 + expect(after.articleThird).not.toBe(before.articleThird); 929 + expect(after.articles).not.toBe(before.articles); 743 930 }); 744 931 });
+2 -2
examples/blog/src/routes/index.rs
··· 4 4 use crate::{ 5 5 content::ArticleContent, 6 6 layout::layout, 7 - routes::{Article, article::ArticleParams}, 7 + routes::{article::ArticleParams, Article}, 8 8 }; 9 9 10 10 #[route("/")] ··· 16 16 17 17 let markup = html! { 18 18 ul { 19 - @for entry in &articles.entries { 19 + @for entry in articles.entries() { 20 20 li { 21 21 a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 22 22 h2 { (entry.data(ctx).title) }
+2 -2
examples/library/src/routes/index.rs
··· 4 4 use crate::{ 5 5 content::ArticleContent, 6 6 layout::layout, 7 - routes::{Article, article::ArticleParams}, 7 + routes::{article::ArticleParams, Article}, 8 8 }; 9 9 10 10 #[route("/")] ··· 18 18 let markup = html! { 19 19 (logo.render("Maudit logo, a crudely drawn crown")) 20 20 ul { 21 - @for entry in &articles.entries { 21 + @for entry in articles.entries() { 22 22 li { 23 23 a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 24 24 h2 { (entry.data(ctx).title) }
+1 -1
examples/oubli-basics/src/routes/index.rs
··· 16 16 Ok(layout(html! { 17 17 (logo.render("Maudit logo, a crudely drawn crown")) 18 18 h1 { "Hello World" } 19 - @for archetype in &archetype_store.entries { 19 + @for archetype in archetype_store.entries() { 20 20 a href=(archetype.id) { (archetype.data(ctx).title) } 21 21 } 22 22 }))
+2 -2
website/src/layout/docs_sidebars.rs
··· 1 - use maud::{Markup, html}; 1 + use maud::{html, Markup}; 2 2 use maudit::{ 3 3 content::MarkdownHeading, 4 4 route::{PageContext, RouteExt}, ··· 14 14 15 15 let mut sections = std::collections::HashMap::new(); 16 16 17 - for entry in content.entries.iter() { 17 + for entry in content.entries() { 18 18 if let Some(section) = &entry.data(ctx).section { 19 19 sections.entry(section).or_insert_with(Vec::new).push(entry); 20 20 }
+3 -3
website/src/routes/news.rs
··· 1 1 use chrono::Datelike; 2 - use maud::PreEscaped; 3 2 use maud::html; 3 + use maud::PreEscaped; 4 4 use maudit::route::prelude::*; 5 5 use std::collections::BTreeMap; 6 6 7 7 use crate::content::NewsContent; 8 - use crate::layout::SeoMeta; 9 8 use crate::layout::layout; 9 + use crate::layout::SeoMeta; 10 10 11 11 #[route("/news/")] 12 12 pub struct NewsIndex; ··· 18 18 // Group articles by year 19 19 let mut articles_by_year: BTreeMap<String, Vec<_>> = BTreeMap::new(); 20 20 21 - for article in &content.entries { 21 + for article in content.entries() { 22 22 let year = article.data(ctx).date.year().to_string(); 23 23 articles_by_year 24 24 .entry(year)