ssg_core/isr_manifest.rs
1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! ISR build manifest — `dist/.ssg/manifest.json`.
5//!
6//! The manifest maps every output URL to its exact source dependency
7//! set (markdown file + layout + partials + data files) plus a content
8//! hash of those dependencies. The Edge renderer consults this manifest
9//! to (a) find which sources to fetch from KV / Edge Config, and (b)
10//! detect cache invalidation when sources change.
11//!
12//! Shape (canonical, stable):
13//!
14//! ```json
15//! {
16//! "version": 1,
17//! "generated_at": "<rfc3339 timestamp or build-id>",
18//! "default_cache": { "s_maxage": 60, "swr": 86400 },
19//! "entries": {
20//! "/posts/foo/index.html": {
21//! "sources": ["content/posts/foo.md", "templates/post.html"],
22//! "hash": "<sha256-hex>",
23//! "cache": { "s_maxage": 600, "swr": 3600 }
24//! }
25//! }
26//! }
27//! ```
28//!
29//! `cache` is omitted at the entry level when the page wants the
30//! site-wide default (`default_cache`). Per-route overrides come from
31//! frontmatter (`isr.s_maxage`, `isr.swr`).
32//!
33//! ## Determinism
34//!
35//! Entries are written in lexicographic URL order so the manifest is
36//! byte-stable for a given input set — critical for CDN cache keys
37//! and reproducible builds.
38
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41use std::collections::BTreeMap;
42
43/// Schema version of the manifest. Bump when the on-disk shape changes
44/// in a way edge adapters need to detect.
45pub const MANIFEST_VERSION: u32 = 1;
46
47/// Default `s-maxage` (seconds) the manifest emits when a page does
48/// not override via frontmatter. 60 s tracks the per-route SLA quoted
49/// in issue #546.
50pub const DEFAULT_S_MAXAGE: u32 = 60;
51
52/// Default `stale-while-revalidate` (seconds). 24 h matches the
53/// `Cache-Control: stale-while-revalidate=86400` snippet in the
54/// architecture doc.
55pub const DEFAULT_SWR: u32 = 86_400;
56
57/// Per-page `Cache-Control` knobs.
58///
59/// Both fields are seconds, both optional at the entry level — when
60/// absent we fall back to the manifest-wide [`Manifest::default_cache`].
61///
62/// # Examples
63///
64/// ```
65/// use ssg_core::CachePolicy;
66///
67/// let policy = CachePolicy { s_maxage: 60, swr: 86_400 };
68/// assert_eq!(
69/// policy.to_cache_control(),
70/// "s-maxage=60, stale-while-revalidate=86400",
71/// );
72/// ```
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub struct CachePolicy {
75 /// `s-maxage` — how long the CDN may serve the cached response
76 /// before considering it stale.
77 pub s_maxage: u32,
78 /// `stale-while-revalidate` — how long the CDN may continue
79 /// serving the stale response while it revalidates in the
80 /// background.
81 pub swr: u32,
82}
83
84impl Default for CachePolicy {
85 fn default() -> Self {
86 Self {
87 s_maxage: DEFAULT_S_MAXAGE,
88 swr: DEFAULT_SWR,
89 }
90 }
91}
92
93impl CachePolicy {
94 /// Renders the policy as a `Cache-Control` header value.
95 ///
96 /// # Examples
97 ///
98 /// ```
99 /// use ssg_core::CachePolicy;
100 ///
101 /// let policy = CachePolicy { s_maxage: 120, swr: 600 };
102 /// assert_eq!(
103 /// policy.to_cache_control(),
104 /// "s-maxage=120, stale-while-revalidate=600",
105 /// );
106 /// ```
107 #[must_use]
108 pub fn to_cache_control(&self) -> String {
109 format!(
110 "s-maxage={}, stale-while-revalidate={}",
111 self.s_maxage, self.swr
112 )
113 }
114}
115
116/// One entry in the ISR manifest — describes how one URL renders.
117///
118/// # Examples
119///
120/// ```
121/// use ssg_core::{build_entry, ManifestEntry};
122///
123/// let entry: ManifestEntry =
124/// build_entry(vec!["a.md".into()], &[b"# A"], None);
125/// assert_eq!(entry.sources, vec!["a.md"]);
126/// assert_eq!(entry.hash.len(), 64);
127/// assert!(entry.cache.is_none());
128/// ```
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ManifestEntry {
131 /// Source dependency keys, in deterministic order. Each entry is
132 /// a path-shaped key that resolves through a `ContentProvider`.
133 pub sources: Vec<String>,
134 /// SHA-256 hex digest of the concatenated dependency bytes.
135 /// Used by the Edge runtime to detect that a re-fetch is needed.
136 pub hash: String,
137 /// Per-page cache override. Omitted from JSON when `None` so the
138 /// adapter falls back to the manifest-wide default.
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub cache: Option<CachePolicy>,
141}
142
143/// Complete ISR build manifest — emitted as `dist/.ssg/manifest.json`.
144///
145/// # Examples
146///
147/// ```
148/// use ssg_core::{build_entry, Manifest};
149///
150/// let mut m = Manifest::new("build-1");
151/// m.insert("/a.html", build_entry(vec!["a.md".into()], &[b"a"], None));
152/// assert_eq!(m.len(), 1);
153/// assert!(m.get("/a.html").is_some());
154/// ```
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Manifest {
157 /// Schema version (currently always [`MANIFEST_VERSION`]).
158 pub version: u32,
159 /// Identifier for the build that produced this manifest. Adapters
160 /// use this to detect a stale KV namespace after a deploy.
161 pub generated_at: String,
162 /// Site-wide cache policy applied when an entry lacks its own
163 /// `cache` field.
164 pub default_cache: CachePolicy,
165 /// URL → entry map. Iteration order is lexicographic.
166 pub entries: BTreeMap<String, ManifestEntry>,
167}
168
169impl Default for Manifest {
170 fn default() -> Self {
171 Self::new("unspecified")
172 }
173}
174
175impl Manifest {
176 /// Constructs an empty manifest stamped with `build_id`.
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use ssg_core::{Manifest, MANIFEST_VERSION};
182 ///
183 /// let m = Manifest::new("build-42");
184 /// assert_eq!(m.version, MANIFEST_VERSION);
185 /// assert_eq!(m.generated_at, "build-42");
186 /// assert!(m.is_empty());
187 /// ```
188 #[must_use]
189 pub fn new(build_id: impl Into<String>) -> Self {
190 Self {
191 version: MANIFEST_VERSION,
192 generated_at: build_id.into(),
193 default_cache: CachePolicy::default(),
194 entries: BTreeMap::new(),
195 }
196 }
197
198 /// Inserts (or replaces) an entry.
199 ///
200 /// # Examples
201 ///
202 /// ```
203 /// use ssg_core::{build_entry, Manifest};
204 ///
205 /// let mut m = Manifest::new("b");
206 /// m.insert("/a.html", build_entry(vec!["a.md".into()], &[b"a"], None));
207 /// assert_eq!(m.len(), 1);
208 /// ```
209 pub fn insert(&mut self, url: impl Into<String>, entry: ManifestEntry) {
210 let _ = self.entries.insert(url.into(), entry);
211 }
212
213 /// Returns the entry for `url`, if any.
214 ///
215 /// # Examples
216 ///
217 /// ```
218 /// use ssg_core::{build_entry, Manifest};
219 ///
220 /// let mut m = Manifest::new("b");
221 /// m.insert("/a.html", build_entry(vec!["a.md".into()], &[b"a"], None));
222 /// assert!(m.get("/a.html").is_some());
223 /// assert!(m.get("/missing").is_none());
224 /// ```
225 #[must_use]
226 pub fn get(&self, url: &str) -> Option<&ManifestEntry> {
227 self.entries.get(url)
228 }
229
230 /// Returns the number of entries.
231 ///
232 /// # Examples
233 ///
234 /// ```
235 /// use ssg_core::{build_entry, Manifest};
236 ///
237 /// let mut m = Manifest::new("b");
238 /// assert_eq!(m.len(), 0);
239 /// m.insert("/a.html", build_entry(vec!["a.md".into()], &[b"a"], None));
240 /// assert_eq!(m.len(), 1);
241 /// ```
242 #[must_use]
243 pub fn len(&self) -> usize {
244 self.entries.len()
245 }
246
247 /// Reports whether the manifest holds no entries.
248 ///
249 /// # Examples
250 ///
251 /// ```
252 /// use ssg_core::{build_entry, Manifest};
253 ///
254 /// let mut m = Manifest::new("b");
255 /// assert!(m.is_empty());
256 /// m.insert("/a.html", build_entry(vec!["a.md".into()], &[b"a"], None));
257 /// assert!(!m.is_empty());
258 /// ```
259 #[must_use]
260 pub fn is_empty(&self) -> bool {
261 self.entries.is_empty()
262 }
263
264 /// Serialises to canonical pretty JSON (stable key order, 2-space
265 /// indent). Suitable for direct write to `dist/.ssg/manifest.json`.
266 ///
267 /// # Errors
268 /// Returns the underlying `serde_json::Error` if any entry is not
269 /// representable (in practice this is unreachable — every field
270 /// is a primitive or a `String`).
271 ///
272 /// # Examples
273 ///
274 /// ```
275 /// use ssg_core::Manifest;
276 ///
277 /// let m = Manifest::new("build-1");
278 /// let json = m.to_pretty_json().unwrap();
279 /// assert!(json.contains("\"version\""));
280 /// assert!(json.contains("\"generated_at\": \"build-1\""));
281 /// ```
282 pub fn to_pretty_json(&self) -> serde_json::Result<String> {
283 serde_json::to_string_pretty(self)
284 }
285
286 /// Returns every URL that depends on the given source key.
287 ///
288 /// Used by the invalidation webhook (AC8): given
289 /// `content/posts/foo.md`, find every URL whose manifest entry
290 /// lists it as a source — typically the post itself plus any
291 /// tag/archive index that includes it.
292 ///
293 /// # Examples
294 ///
295 /// ```
296 /// use ssg_core::{build_entry, Manifest};
297 ///
298 /// let mut m = Manifest::new("b");
299 /// m.insert(
300 /// "/a.html",
301 /// build_entry(vec!["c/a.md".into()], &[b"A"], None),
302 /// );
303 /// m.insert(
304 /// "/b.html",
305 /// build_entry(vec!["c/b.md".into()], &[b"B"], None),
306 /// );
307 /// let deps = m.urls_for_source("c/a.md");
308 /// assert_eq!(deps, vec!["/a.html"]);
309 /// ```
310 #[must_use]
311 pub fn urls_for_source(&self, source_key: &str) -> Vec<String> {
312 self.entries
313 .iter()
314 .filter_map(|(url, entry)| {
315 if entry.sources.iter().any(|s| s == source_key) {
316 Some(url.clone())
317 } else {
318 None
319 }
320 })
321 .collect()
322 }
323}
324
325/// Computes the canonical SHA-256 hex digest for a set of source bytes.
326///
327/// The digest covers every source's *full bytes* in the order they
328/// appear in `sources`, with a `0x00` separator between sources so
329/// `["ab", "c"]` and `["a", "bc"]` produce distinct hashes. Sources
330/// are NOT sorted — callers must hand them in deterministic order.
331///
332/// # Examples
333///
334/// ```
335/// use ssg_core::hash_sources;
336///
337/// let a = hash_sources(&[b"hello", b"world"]);
338/// let b = hash_sources(&[b"world", b"hello"]);
339/// assert_eq!(a.len(), 64);
340/// assert_ne!(a, b, "hash is order-sensitive");
341/// ```
342#[must_use]
343pub fn hash_sources(sources: &[&[u8]]) -> String {
344 let mut hasher = Sha256::new();
345 for (i, src) in sources.iter().enumerate() {
346 if i > 0 {
347 hasher.update([0u8]);
348 }
349 hasher.update(src);
350 }
351 let digest = hasher.finalize();
352 let mut out = String::with_capacity(digest.len() * 2);
353 for byte in digest {
354 let _ =
355 std::fmt::Write::write_fmt(&mut out, format_args!("{byte:02x}"));
356 }
357 out
358}
359
360/// Builds a [`ManifestEntry`] from sources + their bytes.
361///
362/// `sources` and `bytes` MUST be the same length and in matching
363/// order. The resulting entry is hashed via [`hash_sources`].
364///
365/// # Panics
366/// Panics in debug builds if the slice lengths disagree.
367///
368/// # Examples
369///
370/// ```
371/// use ssg_core::build_entry;
372///
373/// let entry = build_entry(
374/// vec!["a".into(), "b".into()],
375/// &[b"alpha", b"beta"],
376/// None,
377/// );
378/// assert_eq!(entry.sources, vec!["a", "b"]);
379/// assert_eq!(entry.hash.len(), 64);
380/// ```
381#[must_use]
382pub fn build_entry(
383 sources: Vec<String>,
384 bytes: &[&[u8]],
385 cache: Option<CachePolicy>,
386) -> ManifestEntry {
387 debug_assert_eq!(
388 sources.len(),
389 bytes.len(),
390 "sources and bytes must align"
391 );
392 let hash = hash_sources(bytes);
393 ManifestEntry {
394 sources,
395 hash,
396 cache,
397 }
398}
399
400// ---------------------------------------------------------------------------
401// Tests
402// ---------------------------------------------------------------------------
403
404#[cfg(test)]
405#[allow(clippy::unwrap_used)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn manifest_round_trip_json() {
411 let mut m = Manifest::new("build-42");
412 m.insert(
413 "/index.html",
414 ManifestEntry {
415 sources: vec![
416 "content/index.md".into(),
417 "templates/base.html".into(),
418 ],
419 hash: hash_sources(&[b"# Home", b"<html></html>"]),
420 cache: None,
421 },
422 );
423 m.insert(
424 "/posts/foo/index.html",
425 ManifestEntry {
426 sources: vec![
427 "content/posts/foo.md".into(),
428 "templates/post.html".into(),
429 ],
430 hash: hash_sources(&[b"# Foo", b"<html><body/></html>"]),
431 cache: Some(CachePolicy {
432 s_maxage: 600,
433 swr: 3600,
434 }),
435 },
436 );
437
438 let json = m.to_pretty_json().unwrap();
439 // Stable order — /index.html before /posts/foo/index.html.
440 let i_idx = json.find("/index.html").unwrap();
441 let p_idx = json.find("/posts/foo/index.html").unwrap();
442 assert!(i_idx < p_idx, "URLs must be lexicographically ordered");
443
444 let parsed: Manifest = serde_json::from_str(&json).unwrap();
445 assert_eq!(parsed, m);
446 }
447
448 #[test]
449 fn cache_policy_to_cache_control() {
450 let p = CachePolicy {
451 s_maxage: 120,
452 swr: 600,
453 };
454 assert_eq!(
455 p.to_cache_control(),
456 "s-maxage=120, stale-while-revalidate=600"
457 );
458 }
459
460 #[test]
461 fn cache_policy_default_matches_constants() {
462 let d = CachePolicy::default();
463 assert_eq!(d.s_maxage, DEFAULT_S_MAXAGE);
464 assert_eq!(d.swr, DEFAULT_SWR);
465 }
466
467 #[test]
468 fn hash_sources_is_order_sensitive() {
469 let a = hash_sources(&[b"hello", b"world"]);
470 let b = hash_sources(&[b"world", b"hello"]);
471 assert_ne!(a, b);
472 }
473
474 #[test]
475 fn hash_sources_avoids_concat_collision() {
476 // ["ab", "c"] vs ["a", "bc"] must hash differently.
477 let a = hash_sources(&[b"ab", b"c"]);
478 let b = hash_sources(&[b"a", b"bc"]);
479 assert_ne!(a, b);
480 }
481
482 #[test]
483 fn hash_sources_stable_for_same_input() {
484 let a = hash_sources(&[b"foo", b"bar"]);
485 let b = hash_sources(&[b"foo", b"bar"]);
486 assert_eq!(a, b);
487 // SHA-256 hex is 64 chars.
488 assert_eq!(a.len(), 64);
489 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
490 }
491
492 #[test]
493 fn build_entry_populates_hash() {
494 let entry = build_entry(
495 vec!["a".into(), "b".into()],
496 &[b"alpha", b"beta"],
497 None,
498 );
499 assert_eq!(entry.sources, vec!["a", "b"]);
500 assert!(entry.cache.is_none());
501 assert_eq!(entry.hash.len(), 64);
502 }
503
504 #[test]
505 fn manifest_get_returns_entry() {
506 let mut m = Manifest::default();
507 let entry = build_entry(vec!["x".into()], &[b"xxx"], None);
508 m.insert("/x.html", entry.clone());
509 assert_eq!(m.get("/x.html"), Some(&entry));
510 assert!(m.get("/missing").is_none());
511 }
512
513 #[test]
514 fn manifest_len_and_is_empty() {
515 let mut m = Manifest::new("b");
516 assert!(m.is_empty());
517 assert_eq!(m.len(), 0);
518 m.insert("/a.html", build_entry(vec!["a".into()], &[b"a"], None));
519 assert!(!m.is_empty());
520 assert_eq!(m.len(), 1);
521 }
522
523 #[test]
524 fn urls_for_source_finds_dependents() {
525 let mut m = Manifest::default();
526 m.insert(
527 "/a.html",
528 build_entry(
529 vec!["c/a.md".into(), "t/base.html".into()],
530 &[b"A", b"T"],
531 None,
532 ),
533 );
534 m.insert(
535 "/b.html",
536 build_entry(
537 vec!["c/b.md".into(), "t/base.html".into()],
538 &[b"B", b"T"],
539 None,
540 ),
541 );
542 m.insert(
543 "/tags/foo.html",
544 build_entry(
545 vec!["c/a.md".into(), "c/b.md".into(), "t/tag.html".into()],
546 &[b"A", b"B", b"TAG"],
547 None,
548 ),
549 );
550
551 let mut deps = m.urls_for_source("c/a.md");
552 deps.sort();
553 assert_eq!(deps, vec!["/a.html", "/tags/foo.html"]);
554
555 let mut tdeps = m.urls_for_source("t/base.html");
556 tdeps.sort();
557 assert_eq!(tdeps, vec!["/a.html", "/b.html"]);
558
559 let none = m.urls_for_source("unknown.md");
560 assert!(none.is_empty());
561 }
562
563 #[test]
564 fn entry_cache_skipped_when_none() {
565 let mut m = Manifest::default();
566 m.insert("/a.html", build_entry(vec!["a.md".into()], &[b"a"], None));
567 let json = m.to_pretty_json().unwrap();
568 // entry has no "cache" key when None
569 assert!(!json.contains("\"cache\""));
570 }
571
572 #[test]
573 fn entry_cache_emitted_when_some() {
574 let mut m = Manifest::default();
575 m.insert(
576 "/a.html",
577 build_entry(
578 vec!["a.md".into()],
579 &[b"a"],
580 Some(CachePolicy {
581 s_maxage: 10,
582 swr: 20,
583 }),
584 ),
585 );
586 let json = m.to_pretty_json().unwrap();
587 assert!(json.contains("\"cache\""));
588 assert!(json.contains("\"s_maxage\": 10"));
589 assert!(json.contains("\"swr\": 20"));
590 }
591
592 #[test]
593 fn manifest_version_is_one() {
594 let m = Manifest::new("x");
595 assert_eq!(m.version, 1);
596 }
597}