Skip to content

Commit cefbc52

Browse files
committed
feat(build): add BuildValue for values computed once per build
1 parent dfadca2 commit cefbc52

11 files changed

Lines changed: 606 additions & 14 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
cargo/maudit: minor
3+
---
4+
5+
Adds `BuildValue`: a value computed once per build from content and shared across every page that reads it with `ctx.build_value(&MY_VALUE)`, with its content dependencies tracked so readers re-render when its inputs change.
6+
7+
Removes the unused `assets()` method from the `ContentContext` trait, and `PageContext::from_static_route` / `from_dynamic_route` now take a `&BuildValueStore` — both only affect code that drives the build loop directly instead of going through `coronate()`.

‎crates/maudit/src/build.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,8 @@ pub async fn build(
402402
None
403403
};
404404

405+
let build_value_store = crate::build_value::BuildValueStore::default();
406+
405407
// Serial page rendering loop.
406408
for route in routes {
407409
let cached_route = CachedRoute::new(*route);
@@ -475,6 +477,7 @@ pub async fn build(
475477
&url,
476478
&options.base_url,
477479
None,
480+
&build_value_store,
478481
);
479482
let result = route.build(&mut page_ctx)?;
480483
let access_log = page_ctx.take_access_log();
@@ -590,6 +593,7 @@ pub async fn build(
590593
&url,
591594
&options.base_url,
592595
None,
596+
&build_value_store,
593597
);
594598
let content = route.build(&mut page_ctx)?;
595599
let mut access_log = page_ctx.take_access_log();
@@ -664,6 +668,7 @@ pub async fn build(
664668
&url,
665669
&options.base_url,
666670
None,
671+
&build_value_store,
667672
))?;
668673

669674
write_route_file(
@@ -760,6 +765,7 @@ pub async fn build(
760765
&url,
761766
&options.base_url,
762767
Some(variant_id.clone()),
768+
&build_value_store,
763769
);
764770
let result = route.build(&mut page_ctx)?;
765771
let access_log = page_ctx.take_access_log();
@@ -880,6 +886,7 @@ pub async fn build(
880886
&url,
881887
&options.base_url,
882888
Some(variant_id.clone()),
889+
&build_value_store,
883890
);
884891
let content = route.build(&mut page_ctx)?;
885892
let mut access_log = page_ctx.take_access_log();
@@ -950,6 +957,7 @@ pub async fn build(
950957
&url,
951958
&options.base_url,
952959
Some(variant_id.clone()),
960+
&build_value_store,
953961
))?;
954962

955963
write_route_file(

‎crates/maudit/src/build_value.rs‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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+
}

‎crates/maudit/src/content.rs‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ pub mod markdown;
1010
mod slugger;
1111
pub mod tracked;
1212

13-
use crate::{
14-
assets::RouteAssets,
15-
route::{DynamicRouteContext, PageContext, PageParams},
16-
};
13+
use crate::route::{DynamicRouteContext, PageContext, PageParams};
1714
pub use markdown::{
1815
components::{
1916
BlockQuoteKind, BlockquoteComponent, CodeComponent, EmphasisComponent, HardBreakComponent,
@@ -174,30 +171,23 @@ pub trait ContentEntry<T> {
174171

175172
impl<T> ContentEntry<T> for Entry<T> {}
176173

177-
/// Trait for contexts that can provide access to content
174+
/// Trait for contexts that can read content: the render-time [`PageContext`], the
175+
/// enumeration-time [`DynamicRouteContext`], and the [`DerivedContext`](crate::build_value::DerivedContext)
176+
/// used to compute [`BuildValue`](crate::build_value::BuildValue)s.
178177
pub trait ContentContext {
179178
fn content(&self) -> &ContentSources;
180-
fn assets(&mut self) -> &mut RouteAssets;
181179
}
182180

183181
impl ContentContext for PageContext<'_> {
184182
fn content(&self) -> &ContentSources {
185183
self.content
186184
}
187-
188-
fn assets(&mut self) -> &mut RouteAssets {
189-
self.assets
190-
}
191185
}
192186

193187
impl ContentContext for DynamicRouteContext<'_> {
194188
fn content(&self) -> &ContentSources {
195189
self.content
196190
}
197-
198-
fn assets(&mut self) -> &mut RouteAssets {
199-
self.assets
200-
}
201191
}
202192

203193
type DataLoadingFn<T> = Box<dyn Fn(&mut dyn ContentContext) -> T + Send + Sync>;

‎crates/maudit/src/content/markdown/shortcodes_tests.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ mod tests {
6060
use crate::{assets::RouteAssets, content::ContentSources};
6161

6262
let content_sources = ContentSources::new(vec![]);
63+
let build_values = crate::build_value::BuildValueStore::default();
6364
let mut page_assets = RouteAssets::new(
6465
&RouteAssetsOptions {
6566
assets_dir: "assets".into(),
@@ -80,6 +81,7 @@ mod tests {
8081
access_log: std::rc::Rc::new(std::cell::RefCell::new(
8182
crate::content::tracked::ContentAccessLog::new(),
8283
)),
84+
build_values: &build_values,
8385
};
8486

8587
f(&mut ctx)

‎crates/maudit/src/content/tracked.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ impl ContentAccessLog {
2929
pub fn merge_entries_read(&mut self, other: &ContentAccessLog) {
3030
self.entries_read.extend(other.entries_read.iter().cloned());
3131
}
32+
33+
/// Merge every dependency from `other` into this log — both specific entry reads
34+
/// and full-source iterations. Used to replay a [`BuildValue`](crate::build_value::BuildValue)'s
35+
/// own content dependencies onto each page that reads it, so the reader is
36+
/// re-rendered whenever the value's inputs change.
37+
pub(crate) fn merge_all(&mut self, other: &ContentAccessLog) {
38+
self.entries_read.extend(other.entries_read.iter().cloned());
39+
self.sources_iterated
40+
.extend(other.sources_iterated.iter().cloned());
41+
}
3242
}
3343

3444
/// A wrapper around [`ContentSource`] that records all accesses

‎crates/maudit/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
88
// Modules the end-user will interact directly or indirectly with
99
pub mod assets;
10+
pub mod build_value;
1011
pub mod content;
1112
pub mod errors;
1213
pub mod route;

0 commit comments

Comments
 (0)