|
| 1 | +//! Build-scoped derived values: data computed once per build from content and shared |
| 2 | +//! across every page that reads it. |
| 3 | +//! |
| 4 | +//! A [`BuildValue`] replaces the fragile pattern of one route computing something during |
| 5 | +//! its `render()` and stashing it in a global for other routes to read. On incremental |
| 6 | +//! builds the producing route can be served from cache — skipping its `render()` — which |
| 7 | +//! leaves the global unset and breaks its readers. A build value is instead computed |
| 8 | +//! lazily *on read*, memoized for the rest of the build, and — crucially — records its own |
| 9 | +//! content dependencies onto every page that reads it, so those pages are re-rendered |
| 10 | +//! exactly when the value's inputs change. |
| 11 | +//! |
| 12 | +//! ## Example |
| 13 | +//! ```rust |
| 14 | +//! use maudit::route::prelude::*; |
| 15 | +//! use maudit::build_value::BuildValue; |
| 16 | +//! # use maudit::content::markdown_entry; |
| 17 | +//! # #[markdown_entry] |
| 18 | +//! # pub struct Article { pub title: String } |
| 19 | +//! |
| 20 | +//! static ARTICLE_COUNT: BuildValue<usize> = BuildValue::new(|ctx| { |
| 21 | +//! ctx.content::<Article>("articles").entries().count() |
| 22 | +//! }); |
| 23 | +//! |
| 24 | +//! #[route("/")] |
| 25 | +//! pub struct Index; |
| 26 | +//! impl Route for Index { |
| 27 | +//! fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { |
| 28 | +//! format!("{} articles", ctx.build_value(&ARTICLE_COUNT)) |
| 29 | +//! } |
| 30 | +//! } |
| 31 | +//! ``` |
| 32 | +
|
| 33 | +use std::any::Any; |
| 34 | +use std::cell::RefCell; |
| 35 | +use std::rc::Rc; |
| 36 | + |
| 37 | +use rustc_hash::FxHashMap; |
| 38 | + |
| 39 | +use crate::content::tracked::{ContentAccessLog, TrackedContentSource}; |
| 40 | +use crate::content::{ContentContext, ContentSources}; |
| 41 | + |
| 42 | +/// A value derived once per build from content, read by any number of pages. |
| 43 | +/// |
| 44 | +/// Declare it as a `static` with a non-capturing closure that reads everything it needs |
| 45 | +/// from its [`DerivedContext`], then read it from a route with |
| 46 | +/// [`PageContext::build_value`](crate::route::PageContext::build_value). The closure runs |
| 47 | +/// at most once per build (the first time any page reads it) and its result is shared. |
| 48 | +pub struct BuildValue<T> { |
| 49 | + compute: fn(&mut DerivedContext) -> T, |
| 50 | +} |
| 51 | + |
| 52 | +impl<T> BuildValue<T> { |
| 53 | + /// Create a build value from a pure computation over content. The closure must not |
| 54 | + /// capture state (so it can live in a `static`); read inputs from the context instead. |
| 55 | + pub const fn new(compute: fn(&mut DerivedContext) -> T) -> Self { |
| 56 | + Self { compute } |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +/// Per-build memoization store for [`BuildValue`]s. Created once at the start of a build |
| 61 | +/// and shared (by reference) with every [`PageContext`](crate::route::PageContext). |
| 62 | +/// |
| 63 | +/// [`coronate`](crate::coronate) manages one automatically; you only construct this |
| 64 | +/// directly when writing a custom build loop. |
| 65 | +#[derive(Default)] |
| 66 | +pub struct BuildValueStore { |
| 67 | + cache: RefCell<FxHashMap<usize, CachedBuildValue>>, |
| 68 | +} |
| 69 | + |
| 70 | +struct CachedBuildValue { |
| 71 | + value: Rc<dyn Any>, |
| 72 | + deps: ContentAccessLog, |
| 73 | +} |
| 74 | + |
| 75 | +/// Context handed to a [`BuildValue`]'s compute closure. Exposes tracked content access |
| 76 | +/// (and nested build values), but deliberately no asset access — a build value is pure |
| 77 | +/// derived data; anything that emits assets belongs to a route's `render()`. |
| 78 | +pub struct DerivedContext<'a> { |
| 79 | + content: &'a ContentSources, |
| 80 | + access_log: Rc<RefCell<ContentAccessLog>>, |
| 81 | + store: &'a BuildValueStore, |
| 82 | +} |
| 83 | + |
| 84 | +impl<'a> DerivedContext<'a> { |
| 85 | + /// Get a tracked content source by name. Reads are recorded as the build value's |
| 86 | + /// dependencies and replayed onto every page that reads the value. |
| 87 | + pub fn content<T: 'static>(&self, name: &str) -> TrackedContentSource<'a, T> { |
| 88 | + TrackedContentSource { |
| 89 | + inner: self.content.get_source::<T>(name), |
| 90 | + source_name: name.to_string(), |
| 91 | + log: self.access_log.clone(), |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + /// Read another build value from within this one. Its dependencies fold into this |
| 96 | + /// value's dependencies. |
| 97 | + pub fn build_value<T: 'static>(&self, def: &'static BuildValue<T>) -> Rc<T> { |
| 98 | + compute(def, self.content, self.store, &self.access_log) |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +impl ContentContext for DerivedContext<'_> { |
| 103 | + fn content(&self) -> &ContentSources { |
| 104 | + self.content |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +/// Resolve a build value: return the memoized result if present, otherwise compute it |
| 109 | +/// with a fresh dependency log. Either way, replay the value's dependencies onto |
| 110 | +/// `reader_log` so the reading page depends on whatever the value was computed from. |
| 111 | +pub(crate) fn compute<T: 'static>( |
| 112 | + def: &'static BuildValue<T>, |
| 113 | + content: &ContentSources, |
| 114 | + store: &BuildValueStore, |
| 115 | + reader_log: &Rc<RefCell<ContentAccessLog>>, |
| 116 | +) -> Rc<T> { |
| 117 | + let key = def as *const BuildValue<T> as usize; |
| 118 | + |
| 119 | + { |
| 120 | + let cache = store.cache.borrow(); |
| 121 | + if let Some(cached) = cache.get(&key) { |
| 122 | + reader_log.borrow_mut().merge_all(&cached.deps); |
| 123 | + return cached |
| 124 | + .value |
| 125 | + .clone() |
| 126 | + .downcast::<T>() |
| 127 | + .expect("build value cached under a mismatched type"); |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + // Compute with a fresh log so the closure's reads become *this value's* deps rather |
| 132 | + // than the current reader's. Nested build_value() calls fold their deps in here too. |
| 133 | + let fresh_log = Rc::new(RefCell::new(ContentAccessLog::new())); |
| 134 | + let mut derived = DerivedContext { |
| 135 | + content, |
| 136 | + access_log: fresh_log.clone(), |
| 137 | + store, |
| 138 | + }; |
| 139 | + let value: Rc<T> = Rc::new((def.compute)(&mut derived)); |
| 140 | + let deps = fresh_log.take(); |
| 141 | + |
| 142 | + reader_log.borrow_mut().merge_all(&deps); |
| 143 | + store.cache.borrow_mut().insert( |
| 144 | + key, |
| 145 | + CachedBuildValue { |
| 146 | + value: value.clone(), |
| 147 | + deps, |
| 148 | + }, |
| 149 | + ); |
| 150 | + value |
| 151 | +} |
| 152 | + |
| 153 | +#[cfg(test)] |
| 154 | +mod tests { |
| 155 | + use super::*; |
| 156 | + use crate::content::{ContentEntry, ContentSource, ContentSources, Entry}; |
| 157 | + use std::sync::atomic::{AtomicUsize, Ordering}; |
| 158 | + |
| 159 | + fn sources() -> ContentSources { |
| 160 | + let source = ContentSource::<String>::new( |
| 161 | + "things", |
| 162 | + Box::new(|| { |
| 163 | + vec![ |
| 164 | + Entry::<String>::create("a".into(), None, None, "one".into(), vec![]), |
| 165 | + Entry::<String>::create("b".into(), None, None, "two".into(), vec![]), |
| 166 | + ] |
| 167 | + }), |
| 168 | + ); |
| 169 | + let mut sources = ContentSources::new(vec![Box::new(source)]); |
| 170 | + sources.init_all(); |
| 171 | + sources |
| 172 | + } |
| 173 | + |
| 174 | + static COMPUTE_COUNT: AtomicUsize = AtomicUsize::new(0); |
| 175 | + static THING_COUNT: BuildValue<usize> = BuildValue::new(|ctx| { |
| 176 | + COMPUTE_COUNT.fetch_add(1, Ordering::SeqCst); |
| 177 | + ctx.content::<String>("things").entries().count() |
| 178 | + }); |
| 179 | + |
| 180 | + #[test] |
| 181 | + fn computes_once_and_replays_deps() { |
| 182 | + let sources = sources(); |
| 183 | + let store = BuildValueStore::default(); |
| 184 | + let reader = Rc::new(RefCell::new(ContentAccessLog::new())); |
| 185 | + |
| 186 | + COMPUTE_COUNT.store(0, Ordering::SeqCst); |
| 187 | + let first = compute(&THING_COUNT, &sources, &store, &reader); |
| 188 | + let second = compute(&THING_COUNT, &sources, &store, &reader); |
| 189 | + |
| 190 | + assert_eq!(*first, 2); |
| 191 | + assert_eq!(*second, 2); |
| 192 | + // Memoized: the closure ran exactly once across both reads. |
| 193 | + assert_eq!(COMPUTE_COUNT.load(Ordering::SeqCst), 1); |
| 194 | + |
| 195 | + // The value iterated "things", so each reader now depends on that source. |
| 196 | + let log = reader.borrow(); |
| 197 | + assert_eq!( |
| 198 | + log.sources_iterated |
| 199 | + .iter() |
| 200 | + .filter(|s| s.as_str() == "things") |
| 201 | + .count(), |
| 202 | + 2, |
| 203 | + "the source dep should be replayed onto every read" |
| 204 | + ); |
| 205 | + } |
| 206 | + |
| 207 | + // Separate build values from THING_COUNT so this test never touches COMPUTE_COUNT |
| 208 | + // (unit tests run in parallel; a shared counter would race). |
| 209 | + static INNER_COUNT: BuildValue<usize> = |
| 210 | + BuildValue::new(|ctx| ctx.content::<String>("things").entries().count()); |
| 211 | + static DERIVED_LEN: BuildValue<usize> = |
| 212 | + BuildValue::new(|ctx| *ctx.build_value(&INNER_COUNT) + 10); |
| 213 | + |
| 214 | + #[test] |
| 215 | + fn nested_build_value_folds_in_deps() { |
| 216 | + let sources = sources(); |
| 217 | + let store = BuildValueStore::default(); |
| 218 | + let reader = Rc::new(RefCell::new(ContentAccessLog::new())); |
| 219 | + |
| 220 | + let value = compute(&DERIVED_LEN, &sources, &store, &reader); |
| 221 | + assert_eq!(*value, 12); |
| 222 | + |
| 223 | + // The nested value iterated "things", so the outer reader inherits that dep. |
| 224 | + assert!( |
| 225 | + reader |
| 226 | + .borrow() |
| 227 | + .sources_iterated |
| 228 | + .iter() |
| 229 | + .any(|s| s == "things") |
| 230 | + ); |
| 231 | + } |
| 232 | +} |
0 commit comments