Skip to main content

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}