···55 content::ArticleContent,
66 layout::layout,
77 routes::{
88- Article, Articles,
98 article::{ArticleParams, ArticlesParams},
99+ Article, Articles,
1010 },
1111};
1212···1818 let mut articles = ctx
1919 .content
2020 .get_source::<ArticleContent>("articles")
2121- .entries
2222- .iter()
2323- .collect::<Vec<_>>(); // Collect into a Vec to allow sorting
2121+ .entries()
2222+ .to_vec(); // Clone into a Vec to allow sorting
24232524 // Sort by date, newest first
2625 articles.sort_by(|a, b| b.data(ctx).date.cmp(&a.data(ctx).date));
+155-12
crates/maudit/src/build.rs
···1919 options::PrefetchStrategy,
2020 state::{BuildState, RouteIdentifier},
2121 },
2222- content::ContentSources,
2222+ content::{ContentSources, finish_tracking_content_files, start_tracking_content_files},
2323 is_dev,
2424 logging::print_title,
2525 route::{CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams},
···142142 build_state.track_source_file(source_path, route_id.clone());
143143}
144144145145+/// Helper to track content files accessed during page rendering.
146146+/// Only performs work when incremental builds are enabled and route_id is provided.
147147+/// This should be called after `finish_tracking_content_files()` to get the accessed files.
148148+fn track_route_content_files(
149149+ build_state: &mut BuildState,
150150+ route_id: Option<&RouteIdentifier>,
151151+ accessed_files: Option<FxHashSet<PathBuf>>,
152152+) {
153153+ // Skip tracking entirely when route_id is not provided (incremental disabled)
154154+ let Some(route_id) = route_id else {
155155+ return;
156156+ };
157157+158158+ // Skip if no files were tracked
159159+ let Some(files) = accessed_files else {
160160+ return;
161161+ };
162162+163163+ for file_path in files {
164164+ build_state.track_content_file(file_path, route_id.clone());
165165+ }
166166+}
167167+145168pub fn execute_build(
146169 routes: &[&dyn FullRoute],
147170 content_sources: &mut ContentSources,
···177200 BuildState::new()
178201 };
179202180180- debug!(target: "build", "Loaded build state with {} asset mappings, {} source mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len());
203203+ 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());
181204 debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some());
182205183206 // Determine if this is an incremental build
···191214 info!(target: "build", "Incremental build: {} files changed", changed.len());
192215 info!(target: "build", "Changed files: {:?}", changed);
193216194194- info!(target: "build", "Build state has {} asset mappings, {} source mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len());
217217+ 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());
195218196219 match build_state.get_affected_routes(changed) {
197220 Some(affected) => {
···287310288311 let content_sources_start = Instant::now();
289312 print_title("initializing content sources");
290290- content_sources.sources_mut().iter_mut().for_each(|source| {
291291- let source_start = Instant::now();
292292- source.init();
313313+314314+ // Determine which content sources need to be initialized
315315+ // For incremental builds, only re-init sources whose files have changed
316316+ let sources_to_init: Option<FxHashSet<String>> = if is_incremental {
317317+ if let Some(changed) = changed_files {
318318+ build_state.get_affected_content_sources(changed)
319319+ } else {
320320+ None // Full init
321321+ }
322322+ } else {
323323+ None // Full init
324324+ };
325325+326326+ // Initialize content sources (all or selective)
327327+ let initialized_sources: Vec<String> = match &sources_to_init {
328328+ Some(source_names) if !source_names.is_empty() => {
329329+ info!(target: "content", "Selectively initializing {} content source(s): {:?}", source_names.len(), source_names);
330330+331331+ // Clear mappings for sources being re-initialized before init
332332+ build_state.clear_content_mappings_for_sources(source_names);
333333+334334+ // Initialize only the affected sources
335335+ let mut initialized = Vec::new();
336336+ for source in content_sources.sources_mut() {
337337+ if source_names.contains(source.get_name()) {
338338+ let source_start = Instant::now();
339339+ source.init();
340340+ info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default()));
341341+ initialized.push(source.get_name().to_string());
342342+ } else {
343343+ info!(target: "content", "{} (unchanged, skipped)", source.get_name());
344344+ }
345345+ }
346346+ initialized
347347+ }
348348+ Some(_) => {
349349+ // Empty set means no content files changed, skip all initialization
350350+ info!(target: "content", "No content files changed, skipping content source initialization");
351351+ Vec::new()
352352+ }
353353+ None => {
354354+ // Full initialization (first build, unknown files, or non-incremental)
355355+ info!(target: "content", "Initializing all content sources");
356356+357357+ // Clear all content mappings for full init
358358+ build_state.clear_content_file_mappings();
359359+ build_state.content_file_to_source.clear();
360360+361361+ let mut initialized = Vec::new();
362362+ for source in content_sources.sources_mut() {
363363+ let source_start = Instant::now();
364364+ source.init();
365365+ info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default()));
366366+ initialized.push(source.get_name().to_string());
367367+ }
368368+ initialized
369369+ }
370370+ };
293371294294- info!(target: "content", "{} initialized in {}", source.get_name(), format_elapsed_time(source_start.elapsed(), &FormatElapsedTimeOptions::default()));
295295- });
372372+ // Track file->source mappings for all initialized sources
373373+ for source in content_sources.sources() {
374374+ if initialized_sources.contains(&source.get_name().to_string()) {
375375+ let source_name = source.get_name().to_string();
376376+ for file_path in source.get_entry_file_paths() {
377377+ build_state.track_content_file_source(file_path, source_name.clone());
378378+ }
379379+ }
380380+ }
296381297382 info!(target: "content", "{}", format!("Content sources initialized in {}", format_elapsed_time(
298383 content_sources_start.elapsed(),
299384 &FormatElapsedTimeOptions::default(),
300385 )).bold());
301386387387+ // Clear content file->routes mappings for routes being rebuilt
388388+ // (so they get fresh tracking during this build)
389389+ if let Some(ref routes) = routes_to_rebuild {
390390+ build_state.clear_content_file_mappings_for_routes(routes);
391391+ }
392392+302393 print_title("generating pages");
303394 let pages_start = Instant::now();
304395···405496 let params = PageParams::default();
406497 let url = cached_route.url(¶ms);
407498499499+ // Start tracking content file access for incremental builds
500500+ if options.incremental {
501501+ start_tracking_content_files();
502502+ }
503503+408504 let result = route.build(&mut PageContext::from_static_route(
409505 content_sources,
410506 &mut route_assets,
···413509 None,
414510 ))?;
415511512512+ // Finish tracking and record accessed content files
513513+ let accessed_files = if options.incremental {
514514+ finish_tracking_content_files()
515515+ } else {
516516+ None
517517+ };
518518+416519 let file_path = cached_route.file_path(¶ms, &options.output_dir);
417520418521 write_route_file(&result, &file_path)?;
419522420523 info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options));
421524422422- // Track assets and source file for this route
525525+ // Track assets, source file, and content files for this route
423526 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets);
424527 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file());
528528+ track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files);
425529426530 build_pages_images.extend(route_assets.images);
427531 build_pages_scripts.extend(route_assets.scripts);
···482586 let url = cached_route.url(&page.0);
483587 let file_path = cached_route.file_path(&page.0, &options.output_dir);
484588589589+ // Start tracking content file access for incremental builds
590590+ if options.incremental {
591591+ start_tracking_content_files();
592592+ }
593593+485594 let content = route.build(&mut PageContext::from_dynamic_route(
486595 &page,
487596 content_sources,
···491600 None,
492601 ))?;
493602603603+ // Finish tracking and record accessed content files
604604+ let accessed_files = if options.incremental {
605605+ finish_tracking_content_files()
606606+ } else {
607607+ None
608608+ };
609609+494610 write_route_file(&content, &file_path)?;
495611496612 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options));
497613498498- // Track assets and source file for this page
614614+ // Track assets, source file, and content files for this page
499615 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets);
500616 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file());
617617+ track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files);
501618502619 build_metadata.add_page(
503620 base_path.clone(),
···558675 &variant_id,
559676 )?;
560677678678+ // Start tracking content file access for incremental builds
679679+ if options.incremental {
680680+ start_tracking_content_files();
681681+ }
682682+561683 let result = route.build(&mut PageContext::from_static_route(
562684 content_sources,
563685 &mut route_assets,
···566688 Some(variant_id.clone()),
567689 ))?;
568690691691+ // Finish tracking and record accessed content files
692692+ let accessed_files = if options.incremental {
693693+ finish_tracking_content_files()
694694+ } else {
695695+ None
696696+ };
697697+569698 write_route_file(&result, &file_path)?;
570699571700 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options));
572701573573- // Track assets and source file for this variant
702702+ // Track assets, source file, and content files for this variant
574703 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets);
575704 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file());
705705+ track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files);
576706577707 build_pages_images.extend(route_assets.images);
578708 build_pages_scripts.extend(route_assets.scripts);
···640770 &variant_id,
641771 )?;
642772773773+ // Start tracking content file access for incremental builds
774774+ if options.incremental {
775775+ start_tracking_content_files();
776776+ }
777777+643778 let content = route.build(&mut PageContext::from_dynamic_route(
644779 &page,
645780 content_sources,
···649784 Some(variant_id.clone()),
650785 ))?;
651786787787+ // Finish tracking and record accessed content files
788788+ let accessed_files = if options.incremental {
789789+ finish_tracking_content_files()
790790+ } else {
791791+ None
792792+ };
793793+652794 write_route_file(&content, &file_path)?;
653795654796 info!(target: "pages", "│ ├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options));
655797656656- // Track assets and source file for this variant page
798798+ // Track assets, source file, and content files for this variant page
657799 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets);
658800 track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file());
801801+ track_route_content_files(&mut build_state, route_id.as_ref(), accessed_files);
659802660803 build_metadata.add_page(
661804 variant_path.clone(),
+554
crates/maudit/src/build/state.rs
···6363 /// Value: set of routes defined in this source file
6464 pub source_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>,
65656666+ /// Maps content file paths to routes that use them
6767+ /// Key: canonicalized content file path (e.g., content/articles/hello.md)
6868+ /// Value: set of routes using this specific content file
6969+ /// This provides granular tracking - if only hello.md changes, only routes
7070+ /// that accessed hello.md need to be rebuilt.
7171+ pub content_file_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>,
7272+7373+ /// Maps content file paths to the content source that owns them
7474+ /// Key: canonicalized content file path (e.g., content/articles/hello.md)
7575+ /// Value: content source name (e.g., "articles")
7676+ /// This allows selective re-initialization of only the content sources
7777+ /// whose files have changed.
7878+ pub content_file_to_source: FxHashMap<PathBuf, String>,
7979+6680 /// Stores all bundler input paths from the last build
6781 /// This needs to be preserved to ensure consistent bundling
6882 pub bundler_inputs: Vec<String>,
···112126 .insert(route_id);
113127 }
114128129129+ /// Add a content file->route mapping
130130+ /// This tracks which specific content files are used by which routes for incremental rebuilds.
131131+ /// This provides granular tracking - only routes that actually accessed a specific file
132132+ /// will be rebuilt when that file changes.
133133+ ///
134134+ /// The file path is canonicalized before storage to ensure consistent lookups when
135135+ /// comparing against absolute paths from the file watcher.
136136+ pub fn track_content_file(&mut self, file_path: PathBuf, route_id: RouteIdentifier) {
137137+ // Canonicalize the path to ensure consistent matching with absolute paths from the watcher
138138+ let canonical_path = file_path.canonicalize().unwrap_or(file_path);
139139+ self.content_file_to_routes
140140+ .entry(canonical_path)
141141+ .or_default()
142142+ .insert(route_id);
143143+ }
144144+145145+ /// Add a content file->source mapping
146146+ /// This tracks which content source owns each file, allowing selective re-initialization
147147+ /// of only the sources whose files have changed.
148148+ ///
149149+ /// The file path is canonicalized before storage to ensure consistent lookups.
150150+ pub fn track_content_file_source(&mut self, file_path: PathBuf, source_name: String) {
151151+ let canonical_path = file_path.canonicalize().unwrap_or(file_path);
152152+ self.content_file_to_source
153153+ .insert(canonical_path, source_name);
154154+ }
155155+156156+ /// Get the names of content sources that have files in the changed files list.
157157+ /// Returns `None` if any changed content file is not tracked (new file), indicating
158158+ /// that all content sources should be re-initialized.
159159+ ///
160160+ /// Only considers files that look like content files (have common content extensions).
161161+ pub fn get_affected_content_sources(&self, changed_files: &[PathBuf]) -> Option<FxHashSet<String>> {
162162+ let content_extensions = ["md", "mdx", "yaml", "yml", "json", "toml"];
163163+ let mut affected_sources = FxHashSet::default();
164164+165165+ for changed_file in changed_files {
166166+ // Skip files that don't look like content files
167167+ let is_content_file = changed_file
168168+ .extension()
169169+ .and_then(|ext| ext.to_str())
170170+ .map(|ext| content_extensions.contains(&ext))
171171+ .unwrap_or(false);
172172+173173+ if !is_content_file {
174174+ continue;
175175+ }
176176+177177+ // Try to find the source for this file
178178+ let canonical = changed_file.canonicalize().ok();
179179+180180+ let source = canonical
181181+ .as_ref()
182182+ .and_then(|c| self.content_file_to_source.get(c))
183183+ .or_else(|| self.content_file_to_source.get(changed_file));
184184+185185+ match source {
186186+ Some(source_name) => {
187187+ affected_sources.insert(source_name.clone());
188188+ }
189189+ None => {
190190+ // Unknown content file - could be a new file
191191+ // Fall back to re-initializing all sources
192192+ return None;
193193+ }
194194+ }
195195+ }
196196+197197+ Some(affected_sources)
198198+ }
199199+115200 /// Get all routes affected by changes to specific files.
116201 ///
117202 /// Returns `Some(routes)` if all changed files were found in the mappings,
···166251 file_was_tracked = true;
167252 }
168253254254+ // Check if this is a content file with direct file->route tracking
255255+ if let Some(canonical) = &canonical_changed
256256+ && let Some(routes) = self.content_file_to_routes.get(canonical)
257257+ {
258258+ affected_routes.extend(routes.iter().cloned());
259259+ file_was_tracked = true;
260260+ }
261261+262262+ // Also check with original path for content files
263263+ if let Some(routes) = self.content_file_to_routes.get(changed_file) {
264264+ affected_routes.extend(routes.iter().cloned());
265265+ file_was_tracked = true;
266266+ }
267267+169268 // Directory prefix check: find all routes using assets within this directory.
170269 // This handles two cases:
171270 // 1. A directory was modified - rebuild all routes using assets in that dir
···193292 file_was_tracked = true;
194293 }
195294 }
295295+ // Also check content files for directory prefix
296296+ for (content_path, routes) in &self.content_file_to_routes {
297297+ if content_path.starts_with(changed_file) {
298298+ affected_routes.extend(routes.iter().cloned());
299299+ file_was_tracked = true;
300300+ }
301301+ }
196302 }
197303198304 // Flag as untracked (triggering full rebuild) if:
···237343 pub fn clear(&mut self) {
238344 self.asset_to_routes.clear();
239345 self.source_to_routes.clear();
346346+ self.content_file_to_routes.clear();
347347+ self.content_file_to_source.clear();
240348 self.bundler_inputs.clear();
241349 }
350350+351351+ /// Clear the content file to routes mapping.
352352+ /// This should be called before re-tracking content files after content sources are re-initialized.
353353+ pub fn clear_content_file_mappings(&mut self) {
354354+ self.content_file_to_routes.clear();
355355+ }
356356+357357+ /// Clear content file mappings for specific sources.
358358+ /// This removes both file->routes and file->source mappings for files owned by the given sources.
359359+ /// Called when selectively re-initializing specific content sources.
360360+ pub fn clear_content_mappings_for_sources(&mut self, source_names: &FxHashSet<String>) {
361361+ // Find all files that belong to the specified sources
362362+ let files_to_remove: Vec<PathBuf> = self
363363+ .content_file_to_source
364364+ .iter()
365365+ .filter(|(_, source)| source_names.contains(*source))
366366+ .map(|(path, _)| path.clone())
367367+ .collect();
368368+369369+ // Remove file->source mappings only
370370+ // We DON'T clear file->routes mappings here because:
371371+ // 1. Routes not being rebuilt should keep their mappings
372372+ // 2. Routes being rebuilt will have their mappings cleared separately
373373+ // via clear_content_file_mappings_for_routes()
374374+ for file in &files_to_remove {
375375+ self.content_file_to_source.remove(file);
376376+ }
377377+ }
378378+379379+ /// Remove content file mappings for specific routes.
380380+ /// This is used during incremental builds to clear only the mappings for routes
381381+ /// that will be rebuilt, preserving mappings for routes that won't change.
382382+ pub fn clear_content_file_mappings_for_routes(&mut self, routes: &FxHashSet<RouteIdentifier>) {
383383+ for routes_set in self.content_file_to_routes.values_mut() {
384384+ routes_set.retain(|route| !routes.contains(route));
385385+ }
386386+ // Remove any entries that have no routes left
387387+ self.content_file_to_routes
388388+ .retain(|_, routes_set| !routes_set.is_empty());
389389+ }
390390+391391+ /// Check if a file path is a known content file.
392392+ /// This is used to determine if a new file might be a content file.
393393+ #[allow(dead_code)] // Used in tests and potentially useful for debugging
394394+ pub fn is_known_content_file(&self, file_path: &Path) -> bool {
395395+ if self.content_file_to_routes.contains_key(file_path) {
396396+ return true;
397397+ }
398398+399399+ // Try with canonicalized path
400400+ if let Ok(canonical) = file_path.canonicalize() {
401401+ return self.content_file_to_routes.contains_key(&canonical);
402402+ }
403403+404404+ false
405405+ }
242406}
243407244408#[cfg(test)]
···564728 println!("Deleted tracked file triggered full rebuild (safe behavior)");
565729 }
566730 }
731731+ }
732732+733733+ #[test]
734734+ fn test_track_content_file() {
735735+ let mut state = BuildState::new();
736736+ let route = make_route("/");
737737+ let content_file = PathBuf::from("/project/content/articles/hello.md");
738738+739739+ state.track_content_file(content_file.clone(), route.clone());
740740+741741+ assert_eq!(state.content_file_to_routes.len(), 1);
742742+ assert!(state.content_file_to_routes.contains_key(&content_file));
743743+ assert!(state.content_file_to_routes[&content_file].contains(&route));
744744+ }
745745+746746+ #[test]
747747+ fn test_track_content_file_multiple_routes() {
748748+ let mut state = BuildState::new();
749749+ let route1 = make_route("/");
750750+ let route2 = make_route("/blog");
751751+ let content_file = PathBuf::from("/project/content/articles/hello.md");
752752+753753+ state.track_content_file(content_file.clone(), route1.clone());
754754+ state.track_content_file(content_file.clone(), route2.clone());
755755+756756+ assert_eq!(state.content_file_to_routes.len(), 1);
757757+ assert_eq!(state.content_file_to_routes[&content_file].len(), 2);
758758+ assert!(state.content_file_to_routes[&content_file].contains(&route1));
759759+ assert!(state.content_file_to_routes[&content_file].contains(&route2));
760760+ }
761761+762762+ #[test]
763763+ fn test_track_content_file_multiple_files() {
764764+ let mut state = BuildState::new();
765765+ let route = make_route("/");
766766+ let file1 = PathBuf::from("/project/content/articles/hello.md");
767767+ let file2 = PathBuf::from("/project/content/articles/world.md");
768768+769769+ state.track_content_file(file1.clone(), route.clone());
770770+ state.track_content_file(file2.clone(), route.clone());
771771+772772+ assert_eq!(state.content_file_to_routes.len(), 2);
773773+ assert!(state.content_file_to_routes[&file1].contains(&route));
774774+ assert!(state.content_file_to_routes[&file2].contains(&route));
775775+ }
776776+777777+ #[test]
778778+ fn test_clear_also_clears_content_files() {
779779+ let mut state = BuildState::new();
780780+ let route = make_route("/");
781781+ let content_file = PathBuf::from("/project/content/articles/hello.md");
782782+783783+ state.track_content_file(content_file, route);
784784+785785+ assert!(!state.content_file_to_routes.is_empty());
786786+787787+ state.clear();
788788+789789+ assert!(state.content_file_to_routes.is_empty());
790790+ }
791791+792792+ #[test]
793793+ fn test_get_affected_routes_content_file() {
794794+ let mut state = BuildState::new();
795795+ let route1 = make_route("/");
796796+ let route2 = make_route("/blog/[slug]");
797797+ let route3 = make_route("/about");
798798+799799+ // Track content file -> route mappings directly
800800+ let article1 = PathBuf::from("/project/content/articles/hello.md");
801801+ let article2 = PathBuf::from("/project/content/articles/world.md");
802802+ let page1 = PathBuf::from("/project/content/pages/about.md");
803803+804804+ // Route "/" uses article1 and article2
805805+ state.track_content_file(article1.clone(), route1.clone());
806806+ state.track_content_file(article2.clone(), route1.clone());
807807+ // Route "/blog/[slug]" uses only article1
808808+ state.track_content_file(article1.clone(), route2.clone());
809809+ // Route "/about" uses page1
810810+ state.track_content_file(page1.clone(), route3.clone());
811811+812812+ // When article1 changes, only routes that used article1 should be affected
813813+ let affected = state.get_affected_routes(&[article1]).unwrap();
814814+ assert_eq!(affected.len(), 2);
815815+ assert!(affected.contains(&route1));
816816+ assert!(affected.contains(&route2));
817817+ assert!(!affected.contains(&route3));
818818+819819+ // When article2 changes, only route1 should be affected (granular!)
820820+ let affected = state.get_affected_routes(&[article2]).unwrap();
821821+ assert_eq!(affected.len(), 1);
822822+ assert!(affected.contains(&route1));
823823+ assert!(!affected.contains(&route2));
824824+ assert!(!affected.contains(&route3));
825825+826826+ // When page1 changes, only route3 should be affected
827827+ let affected = state.get_affected_routes(&[page1]).unwrap();
828828+ assert_eq!(affected.len(), 1);
829829+ assert!(affected.contains(&route3));
830830+ assert!(!affected.contains(&route1));
831831+ assert!(!affected.contains(&route2));
832832+ }
833833+834834+ #[test]
835835+ fn test_get_affected_routes_content_file_multiple_files_changed() {
836836+ let mut state = BuildState::new();
837837+ let route1 = make_route("/");
838838+ let route2 = make_route("/about");
839839+840840+ // Track content files
841841+ let article = PathBuf::from("/project/content/articles/hello.md");
842842+ let page = PathBuf::from("/project/content/pages/about.md");
843843+844844+ state.track_content_file(article.clone(), route1.clone());
845845+ state.track_content_file(page.clone(), route2.clone());
846846+847847+ // When both files change, both routes should be affected
848848+ let affected = state.get_affected_routes(&[article, page]).unwrap();
849849+ assert_eq!(affected.len(), 2);
850850+ assert!(affected.contains(&route1));
851851+ assert!(affected.contains(&route2));
852852+ }
853853+854854+ #[test]
855855+ fn test_get_affected_routes_content_file_mixed_with_asset() {
856856+ let mut state = BuildState::new();
857857+ let route1 = make_route("/");
858858+ let route2 = make_route("/about");
859859+860860+ // Track a content file for route1
861861+ let article = PathBuf::from("/project/content/articles/hello.md");
862862+ state.track_content_file(article.clone(), route1.clone());
863863+864864+ // Track an asset used by route2
865865+ let style = PathBuf::from("/project/src/styles.css");
866866+ state.track_asset(style.clone(), route2.clone());
867867+868868+ // When both content file and asset change
869869+ let affected = state.get_affected_routes(&[article, style]).unwrap();
870870+ assert_eq!(affected.len(), 2);
871871+ assert!(affected.contains(&route1));
872872+ assert!(affected.contains(&route2));
873873+ }
874874+875875+ #[test]
876876+ fn test_get_affected_routes_unknown_content_file() {
877877+ let mut state = BuildState::new();
878878+ let route = make_route("/");
879879+880880+ // Track a content file
881881+ let article = PathBuf::from("/project/content/articles/hello.md");
882882+ state.track_content_file(article, route);
883883+884884+ // A new/unknown .md file that isn't tracked
885885+ // This could be a newly created file
886886+ let new_file = PathBuf::from("/project/content/articles/new-post.md");
887887+888888+ // Should trigger full rebuild since it's an untracked file with extension
889889+ let affected = state.get_affected_routes(&[new_file]);
890890+ assert!(
891891+ affected.is_none(),
892892+ "New untracked content file should trigger full rebuild"
893893+ );
894894+ }
895895+896896+ #[test]
897897+ fn test_is_known_content_file() {
898898+ let mut state = BuildState::new();
899899+ let route = make_route("/");
900900+ let content_file = PathBuf::from("/project/content/articles/hello.md");
901901+902902+ state.track_content_file(content_file.clone(), route);
903903+904904+ assert!(state.is_known_content_file(&content_file));
905905+ assert!(!state.is_known_content_file(Path::new("/project/content/articles/unknown.md")));
906906+ }
907907+908908+ #[test]
909909+ fn test_content_file_directory_prefix() {
910910+ let mut state = BuildState::new();
911911+ let route = make_route("/");
912912+913913+ // Track content files under a directory
914914+ let article1 = PathBuf::from("/project/content/articles/hello.md");
915915+ let article2 = PathBuf::from("/project/content/articles/world.md");
916916+ state.track_content_file(article1, route.clone());
917917+ state.track_content_file(article2, route.clone());
918918+919919+ // When the parent directory changes (e.g., renamed), should find affected routes
920920+ let content_dir = PathBuf::from("/project/content/articles");
921921+ let affected = state.get_affected_routes(&[content_dir]).unwrap();
922922+ assert_eq!(affected.len(), 1);
923923+ assert!(affected.contains(&route));
924924+ }
925925+926926+ #[test]
927927+ fn test_clear_content_file_mappings_for_routes() {
928928+ let mut state = BuildState::new();
929929+ let route1 = make_route("/articles");
930930+ let route2 = make_route("/articles/[slug]");
931931+ let route3 = make_route("/about");
932932+933933+ // Article 1 is accessed by routes 1 and 2
934934+ let article1 = PathBuf::from("/project/content/articles/hello.md");
935935+ state.track_content_file(article1.clone(), route1.clone());
936936+ state.track_content_file(article1.clone(), route2.clone());
937937+938938+ // Article 2 is accessed by routes 1 and 2
939939+ let article2 = PathBuf::from("/project/content/articles/world.md");
940940+ state.track_content_file(article2.clone(), route1.clone());
941941+ state.track_content_file(article2.clone(), route2.clone());
942942+943943+ // Route 3 uses a different file
944944+ let page = PathBuf::from("/project/content/pages/about.md");
945945+ state.track_content_file(page.clone(), route3.clone());
946946+947947+ assert_eq!(state.content_file_to_routes.len(), 3);
948948+949949+ // Clear mappings only for route2
950950+ let mut routes_to_clear = FxHashSet::default();
951951+ routes_to_clear.insert(route2.clone());
952952+ state.clear_content_file_mappings_for_routes(&routes_to_clear);
953953+954954+ // route2 should be removed from article1 and article2 mappings
955955+ assert!(!state.content_file_to_routes[&article1].contains(&route2));
956956+ assert!(state.content_file_to_routes[&article1].contains(&route1));
957957+958958+ assert!(!state.content_file_to_routes[&article2].contains(&route2));
959959+ assert!(state.content_file_to_routes[&article2].contains(&route1));
960960+961961+ // route3's mapping should be unaffected
962962+ assert!(state.content_file_to_routes[&page].contains(&route3));
963963+ }
964964+965965+ #[test]
966966+ fn test_clear_content_file_mappings_for_routes_removes_empty_entries() {
967967+ let mut state = BuildState::new();
968968+ let route1 = make_route("/articles/first");
969969+ let route2 = make_route("/articles/second");
970970+971971+ // Route1 uses only article1
972972+ let article1 = PathBuf::from("/project/content/articles/first.md");
973973+ state.track_content_file(article1.clone(), route1.clone());
974974+975975+ // Route2 uses only article2
976976+ let article2 = PathBuf::from("/project/content/articles/second.md");
977977+ state.track_content_file(article2.clone(), route2.clone());
978978+979979+ assert_eq!(state.content_file_to_routes.len(), 2);
980980+981981+ // Clear mappings for route1
982982+ let mut routes_to_clear = FxHashSet::default();
983983+ routes_to_clear.insert(route1);
984984+ state.clear_content_file_mappings_for_routes(&routes_to_clear);
985985+986986+ // article1 entry should be completely removed (no routes left)
987987+ assert!(!state.content_file_to_routes.contains_key(&article1));
988988+989989+ // article2 entry should still exist
990990+ assert!(state.content_file_to_routes.contains_key(&article2));
991991+ assert!(state.content_file_to_routes[&article2].contains(&route2));
992992+ }
993993+994994+ #[test]
995995+ fn test_track_content_file_source() {
996996+ let mut state = BuildState::new();
997997+ let file = PathBuf::from("/project/content/articles/hello.md");
998998+999999+ state.track_content_file_source(file.clone(), "articles".to_string());
10001000+10011001+ assert_eq!(state.content_file_to_source.len(), 1);
10021002+ assert_eq!(state.content_file_to_source.get(&file), Some(&"articles".to_string()));
10031003+ }
10041004+10051005+ #[test]
10061006+ fn test_get_affected_content_sources_single_source() {
10071007+ let mut state = BuildState::new();
10081008+ let article1 = PathBuf::from("/project/content/articles/hello.md");
10091009+ let article2 = PathBuf::from("/project/content/articles/world.md");
10101010+10111011+ state.track_content_file_source(article1.clone(), "articles".to_string());
10121012+ state.track_content_file_source(article2.clone(), "articles".to_string());
10131013+10141014+ // Change one article file
10151015+ let affected = state.get_affected_content_sources(&[article1]).unwrap();
10161016+ assert_eq!(affected.len(), 1);
10171017+ assert!(affected.contains("articles"));
10181018+ }
10191019+10201020+ #[test]
10211021+ fn test_get_affected_content_sources_multiple_sources() {
10221022+ let mut state = BuildState::new();
10231023+ let article = PathBuf::from("/project/content/articles/hello.md");
10241024+ let page = PathBuf::from("/project/content/pages/about.md");
10251025+10261026+ state.track_content_file_source(article.clone(), "articles".to_string());
10271027+ state.track_content_file_source(page.clone(), "pages".to_string());
10281028+10291029+ // Change both files
10301030+ let affected = state.get_affected_content_sources(&[article, page]).unwrap();
10311031+ assert_eq!(affected.len(), 2);
10321032+ assert!(affected.contains("articles"));
10331033+ assert!(affected.contains("pages"));
10341034+ }
10351035+10361036+ #[test]
10371037+ fn test_get_affected_content_sources_unknown_file_returns_none() {
10381038+ let mut state = BuildState::new();
10391039+ let article = PathBuf::from("/project/content/articles/hello.md");
10401040+ state.track_content_file_source(article, "articles".to_string());
10411041+10421042+ // A new file that's not tracked
10431043+ let new_file = PathBuf::from("/project/content/articles/new-post.md");
10441044+10451045+ // Should return None (need to re-init all sources)
10461046+ let affected = state.get_affected_content_sources(&[new_file]);
10471047+ assert!(affected.is_none());
10481048+ }
10491049+10501050+ #[test]
10511051+ fn test_get_affected_content_sources_ignores_non_content_files() {
10521052+ let mut state = BuildState::new();
10531053+ let article = PathBuf::from("/project/content/articles/hello.md");
10541054+ state.track_content_file_source(article.clone(), "articles".to_string());
10551055+10561056+ // A non-content file (e.g., .rs file) - should be ignored
10571057+ let rust_file = PathBuf::from("/project/src/pages/index.rs");
10581058+10591059+ // Should return empty set (no content sources affected)
10601060+ let affected = state.get_affected_content_sources(&[rust_file.clone()]).unwrap();
10611061+ assert!(affected.is_empty());
10621062+10631063+ // Mixed: content file + non-content file
10641064+ let affected = state.get_affected_content_sources(&[article, rust_file]).unwrap();
10651065+ assert_eq!(affected.len(), 1);
10661066+ assert!(affected.contains("articles"));
10671067+ }
10681068+10691069+ #[test]
10701070+ fn test_clear_content_mappings_for_sources() {
10711071+ let mut state = BuildState::new();
10721072+ let route1 = make_route("/articles");
10731073+ let route2 = make_route("/pages");
10741074+10751075+ // Set up articles source
10761076+ let article1 = PathBuf::from("/project/content/articles/hello.md");
10771077+ let article2 = PathBuf::from("/project/content/articles/world.md");
10781078+ state.track_content_file_source(article1.clone(), "articles".to_string());
10791079+ state.track_content_file_source(article2.clone(), "articles".to_string());
10801080+ state.track_content_file(article1.clone(), route1.clone());
10811081+ state.track_content_file(article2.clone(), route1.clone());
10821082+10831083+ // Set up pages source
10841084+ let page = PathBuf::from("/project/content/pages/about.md");
10851085+ state.track_content_file_source(page.clone(), "pages".to_string());
10861086+ state.track_content_file(page.clone(), route2.clone());
10871087+10881088+ assert_eq!(state.content_file_to_source.len(), 3);
10891089+ assert_eq!(state.content_file_to_routes.len(), 3);
10901090+10911091+ // Clear only the articles source
10921092+ let mut sources_to_clear = FxHashSet::default();
10931093+ sources_to_clear.insert("articles".to_string());
10941094+ state.clear_content_mappings_for_sources(&sources_to_clear);
10951095+10961096+ // Articles source mappings should be removed
10971097+ assert!(!state.content_file_to_source.contains_key(&article1));
10981098+ assert!(!state.content_file_to_source.contains_key(&article2));
10991099+11001100+ // But routes mappings should be preserved (cleared separately per-route)
11011101+ assert!(state.content_file_to_routes.contains_key(&article1));
11021102+ assert!(state.content_file_to_routes.contains_key(&article2));
11031103+11041104+ // Pages should remain completely unchanged
11051105+ assert!(state.content_file_to_source.contains_key(&page));
11061106+ assert!(state.content_file_to_routes.contains_key(&page));
11071107+ assert_eq!(state.content_file_to_source.get(&page), Some(&"pages".to_string()));
11081108+ }
11091109+11101110+ #[test]
11111111+ fn test_clear_also_clears_content_file_to_source() {
11121112+ let mut state = BuildState::new();
11131113+ let file = PathBuf::from("/project/content/articles/hello.md");
11141114+ state.track_content_file_source(file, "articles".to_string());
11151115+11161116+ assert!(!state.content_file_to_source.is_empty());
11171117+11181118+ state.clear();
11191119+11201120+ assert!(state.content_file_to_source.is_empty());
5671121 }
5681122}
+104-6
crates/maudit/src/content.rs
···11//! Core functions and structs to define the content sources of your website.
22//!
33//! 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.
44-use std::{any::Any, path::PathBuf, sync::Arc};
44+use std::{any::Any, cell::RefCell, path::PathBuf, sync::Arc};
5566-use rustc_hash::FxHashMap;
66+use rustc_hash::{FxHashMap, FxHashSet};
7788mod highlight;
99pub mod markdown;
···2525};
26262727pub use highlight::{HighlightOptions, highlight_code};
2828+2929+// Thread-local storage for tracking content file access during page rendering.
3030+// This allows us to transparently track which content files a page uses
3131+// without requiring changes to user code.
3232+thread_local! {
3333+ static ACCESSED_CONTENT_FILES: RefCell<Option<FxHashSet<PathBuf>>> = const { RefCell::new(None) };
3434+}
3535+3636+/// Start tracking content file access for a page render.
3737+/// Call this before rendering a page, then call `finish_tracking_content_files()`
3838+/// after rendering to get the set of accessed content files.
3939+pub(crate) fn start_tracking_content_files() {
4040+ ACCESSED_CONTENT_FILES.with(|cell| {
4141+ *cell.borrow_mut() = Some(FxHashSet::default());
4242+ });
4343+}
4444+4545+/// Finish tracking content file access and return the set of accessed files.
4646+/// Returns `None` if tracking was not started.
4747+pub(crate) fn finish_tracking_content_files() -> Option<FxHashSet<PathBuf>> {
4848+ ACCESSED_CONTENT_FILES.with(|cell| cell.borrow_mut().take())
4949+}
5050+5151+/// Record that a content file was accessed.
5252+/// This is called internally when entries are accessed.
5353+fn track_content_file_access(file_path: &PathBuf) {
5454+ ACCESSED_CONTENT_FILES.with(|cell| {
5555+ if let Some(ref mut set) = *cell.borrow_mut() {
5656+ set.insert(file_path.clone());
5757+ }
5858+ });
5959+}
28602961/// Helps implement a struct as a Markdown content entry.
3062///
···302334 }
303335 }
304336337337+ /// Initialize only the content sources with the given names.
338338+ /// Sources not in the set are left untouched (their entries remain as-is).
339339+ /// Returns the names of sources that were actually initialized.
340340+ pub fn init_sources(&mut self, source_names: &rustc_hash::FxHashSet<String>) -> Vec<String> {
341341+ let mut initialized = Vec::new();
342342+ for source in &mut self.0 {
343343+ if source_names.contains(source.get_name()) {
344344+ source.init();
345345+ initialized.push(source.get_name().to_string());
346346+ }
347347+ }
348348+ initialized
349349+ }
350350+305351 pub fn get_untyped_source(&self, name: &str) -> &ContentSource<Untyped> {
306352 self.get_source::<Untyped>(name)
307353 }
···337383/// A source of content such as articles, blog posts, etc.
338384pub struct ContentSource<T = Untyped> {
339385 pub name: String,
340340- pub entries: Vec<Arc<EntryInner<T>>>,
386386+ entries: Vec<Arc<EntryInner<T>>>,
341387 pub(crate) init_method: ContentSourceInitMethod<T>,
342388}
343389···354400 }
355401356402 pub fn get_entry(&self, id: &str) -> &Entry<T> {
357357- self.entries
403403+ let entry = self
404404+ .entries
358405 .iter()
359406 .find(|entry| entry.id == id)
360360- .unwrap_or_else(|| panic!("Entry with id '{}' not found", id))
407407+ .unwrap_or_else(|| panic!("Entry with id '{}' not found", id));
408408+409409+ // Track file access for incremental builds
410410+ if let Some(ref file_path) = entry.file_path {
411411+ track_content_file_access(file_path);
412412+ }
413413+414414+ entry
361415 }
362416363417 pub fn get_entry_safe(&self, id: &str) -> Option<&Entry<T>> {
364364- self.entries.iter().find(|entry| entry.id == id)
418418+ let entry = self.entries.iter().find(|entry| entry.id == id);
419419+420420+ // Track file access for incremental builds
421421+ if let Some(entry) = &entry
422422+ && let Some(ref file_path) = entry.file_path {
423423+ track_content_file_access(file_path);
424424+ }
425425+426426+ entry
365427 }
366428367429 pub fn into_params<P>(&self, cb: impl FnMut(&Entry<T>) -> P) -> Vec<P>
368430 where
369431 P: Into<PageParams>,
370432 {
433433+ // Track all entries accessed for incremental builds
434434+ for entry in &self.entries {
435435+ if let Some(ref file_path) = entry.file_path {
436436+ track_content_file_access(file_path);
437437+ }
438438+ }
371439 self.entries.iter().map(cb).collect()
372440 }
373441···378446 where
379447 Params: Into<PageParams>,
380448 {
449449+ // Track all entries accessed for incremental builds
450450+ for entry in &self.entries {
451451+ if let Some(ref file_path) = entry.file_path {
452452+ track_content_file_access(file_path);
453453+ }
454454+ }
381455 self.entries.iter().map(cb).collect()
382456 }
457457+458458+ /// Get all entries, tracking access for incremental builds.
459459+ ///
460460+ /// This returns a slice of all entries in the content source.
461461+ /// You can use standard slice methods like `.iter()`, `.len()`, `.is_empty()`, etc.
462462+ pub fn entries(&self) -> &[Entry<T>] {
463463+ // Track all entries accessed for incremental builds
464464+ for entry in &self.entries {
465465+ if let Some(ref file_path) = entry.file_path {
466466+ track_content_file_access(file_path);
467467+ }
468468+ }
469469+ &self.entries
470470+ }
383471}
384472385473#[doc(hidden)]
···389477 fn init(&mut self);
390478 fn get_name(&self) -> &str;
391479 fn as_any(&self) -> &dyn Any; // Used for type checking at runtime
480480+481481+ /// Get all file paths for entries in this content source.
482482+ /// Used for incremental builds to map content files to their source.
483483+ fn get_entry_file_paths(&self) -> Vec<PathBuf>;
392484}
393485394486impl<T: 'static + Sync + Send> ContentSourceInternal for ContentSource<T> {
···400492 }
401493 fn as_any(&self) -> &dyn Any {
402494 self
495495+ }
496496+ fn get_entry_file_paths(&self) -> Vec<PathBuf> {
497497+ self.entries
498498+ .iter()
499499+ .filter_map(|entry| entry.file_path.clone())
500500+ .collect()
403501 }
404502}
+1-1
crates/maudit/src/route.rs
···282282/// impl Route for Index {
283283/// fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
284284/// let logo = ctx.assets.add_image("logo.png")?;
285285-/// let last_entries = &ctx.content.get_source::<ArticleContent>("articles").entries;
285285+/// let last_entries = ctx.content.get_source::<ArticleContent>("articles").entries();
286286///
287287/// Ok(html! {
288288/// main {
+3-3
crates/oubli/src/archetypes/blog.rs
···11//! Blog archetype.
22//! Represents a markdown blog archetype, with an index page and individual entry pages.
33use crate::layouts::layout;
44-use maud::{Markup, html};
44+use maud::{html, Markup};
55use maudit::content::markdown_entry;
66-use maudit::route::FullRoute;
76use maudit::route::prelude::*;
77+use maudit::route::FullRoute;
8899pub fn blog_index_content<T: FullRoute>(
1010 route: impl FullRoute,
···18181919 let markup = html! {
2020 main {
2121- @for entry in &blog_entries.entries {
2121+ @for entry in blog_entries.entries() {
2222 a href=(route.url(&BlogEntryParams { entry: entry.id.clone() }.into())) {
2323 h2 { (entry.data(ctx).title) }
2424 p { (entry.data(ctx).description) }