Skip to main content

maincopy_server/render/
catalog.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use markdown_compiler::{
4    AssetRevisionReference, LogicalAssetPath, LogicalContentPath, PostId, PostRevisionDigest,
5    PreparedContent, PublicationSettings, ResolvedLocalAssetLookupError, ResolvedLocalAssetStore,
6    ResolvedSiteAssets,
7};
8use serde::Serialize;
9use thiserror::Error;
10
11use super::{
12    MarkdownRenderError, RenderedPost,
13    diagram::{DiagramRenderError, MermaidDiagramRenderer},
14    markdown::render_prepared_post,
15};
16#[cfg(test)]
17use markdown_compiler::{DigestedAsset, ResolvedPostAssets};
18
19/// Compile one validated candidate into a self-contained immutable catalog.
20pub fn compile_content_catalog(
21    content: &PreparedContent,
22) -> Result<ContentCatalog, CatalogBuildError> {
23    let compiler = ContentCompiler::discover().map_err(CatalogBuildError::compiler)?;
24    compiler.compile(content)
25}
26
27/// One application-owned compilation capability with shared renderer admission.
28#[derive(Clone, Debug)]
29pub(crate) struct ContentCompiler {
30    diagrams: Arc<MermaidDiagramRenderer>,
31}
32
33impl ContentCompiler {
34    pub(crate) fn discover() -> Result<Self, ContentCompilerInitializationError> {
35        let diagrams = MermaidDiagramRenderer::discover()
36            .map_err(ContentCompilerInitializationError::Diagram)?;
37        Ok(Self {
38            diagrams: Arc::new(diagrams),
39        })
40    }
41
42    pub(crate) fn compile(
43        &self,
44        content: &PreparedContent,
45    ) -> Result<ContentCatalog, CatalogBuildError> {
46        let content = content.view();
47        let local_assets = Arc::new(content.local_assets.clone());
48        let mut current_revisions = BTreeMap::new();
49        let mut revisions = BTreeMap::new();
50
51        for post in content.posts {
52            let rendered = render_prepared_post(post, content.site_assets, &self.diagrams)
53                .map_err(CatalogBuildError::render)?;
54            let key = (
55                rendered.document.metadata.id.clone(),
56                rendered.revision.clone(),
57            );
58            // Preparation guarantees one document per post ID.
59            current_revisions.insert(key.0.clone(), key.1.clone());
60            revisions.insert(
61                key,
62                CatalogRevision {
63                    rendered: Arc::new(rendered),
64                    local_assets: Arc::clone(&local_assets),
65                },
66            );
67        }
68
69        Ok(ContentCatalog {
70            publication: content.publication.clone(),
71            site_assets: content.site_assets.clone(),
72            local_assets,
73            current_revisions,
74            revisions,
75        })
76    }
77}
78
79#[derive(Debug, Error)]
80pub(crate) enum ContentCompilerInitializationError {
81    #[error("initialize the supervised Mermaid renderer")]
82    Diagram(#[source] DiagramRenderError),
83}
84
85/// Current candidate revisions plus exact retained historical render inputs.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct ContentCatalog {
88    pub(crate) publication: PublicationSettings,
89    pub(super) site_assets: ResolvedSiteAssets,
90    pub(super) local_assets: Arc<ResolvedLocalAssetStore>,
91    current_revisions: BTreeMap<PostId, PostRevisionDigest>,
92    revisions: BTreeMap<(PostId, PostRevisionDigest), CatalogRevision>,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
96struct CatalogRevision {
97    rendered: Arc<RenderedPost>,
98    local_assets: Arc<ResolvedLocalAssetStore>,
99}
100
101impl ContentCatalog {
102    pub fn get(&self, post_id: &PostId, revision: &PostRevisionDigest) -> Option<&RenderedPost> {
103        self.revisions
104            .get(&(post_id.clone(), revision.clone()))
105            .map(|revision| revision.rendered.as_ref())
106    }
107
108    pub(super) fn get_with_local_assets(
109        &self,
110        post_id: &PostId,
111        revision: &PostRevisionDigest,
112    ) -> Option<(&RenderedPost, &ResolvedLocalAssetStore)> {
113        self.revisions
114            .get(&(post_id.clone(), revision.clone()))
115            .map(|revision| (revision.rendered.as_ref(), revision.local_assets.as_ref()))
116    }
117
118    /// Finds the one revision supplied by this candidate content tree for a post.
119    pub(crate) fn current_post(&self, post_id: &PostId) -> Option<&RenderedPost> {
120        let revision = self.current_revisions.get(post_id)?;
121        self.get(post_id, revision)
122    }
123
124    /// Returns only the revisions supplied by the current candidate content tree.
125    pub(crate) fn rendered_posts(&self) -> impl ExactSizeIterator<Item = &RenderedPost> {
126        self.current_revisions.iter().map(|(post_id, revision)| {
127            self.get(post_id, revision)
128                .expect("every current catalog key has a retained rendered revision")
129        })
130    }
131
132    /// Resolves one referenced asset from the current candidate without exposing retained history.
133    pub(crate) fn current_preview_asset(
134        &self,
135        path: &LogicalAssetPath,
136    ) -> Result<Option<Arc<[u8]>>, ResolvedLocalAssetLookupError> {
137        let authored = self
138            .site_assets
139            .favicon
140            .iter()
141            .chain(self.site_assets.image.iter())
142            .chain(self.site_assets.references.iter())
143            .chain(self.rendered_posts().flat_map(|post| {
144                post.assets
145                    .image
146                    .iter()
147                    .chain(post.assets.references.iter())
148            }))
149            .find_map(|reference| match reference {
150                AssetRevisionReference::Local(asset) if &asset.path == path => Some(asset),
151                AssetRevisionReference::Local(_) | AssetRevisionReference::External(_) => None,
152            });
153        let Some(reference) = authored else {
154            return Ok(None);
155        };
156        let resolved = self.local_assets.resolve(reference)?;
157        Ok(Some(Arc::clone(&resolved.bytes)))
158    }
159
160    /// Atomically retains an explicit set of exact historical revision inputs.
161    pub(crate) fn retain_revisions_from(
162        &mut self,
163        prior: &Self,
164        revisions: impl IntoIterator<Item = (PostId, PostRevisionDigest)>,
165    ) -> Result<(), CatalogRetentionError> {
166        let mut retained = BTreeMap::new();
167        for key in revisions {
168            if self.revisions.contains_key(&key) {
169                continue;
170            }
171            let revision =
172                prior
173                    .revisions
174                    .get(&key)
175                    .cloned()
176                    .ok_or_else(|| CatalogRetentionError {
177                        post_id: key.0.clone(),
178                        revision: key.1.clone(),
179                    })?;
180            retained.insert(key, revision);
181        }
182        self.revisions.extend(retained);
183        Ok(())
184    }
185}
186
187#[derive(Clone, Debug, Eq, Error, PartialEq)]
188#[error("prior catalog does not retain post {post_id} revision {revision}")]
189pub(crate) struct CatalogRetentionError {
190    post_id: PostId,
191    revision: PostRevisionDigest,
192}
193
194#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
195#[serde(rename_all = "snake_case")]
196pub enum CatalogBuildErrorCode {
197    ContentCompilerUnavailable,
198    PostRenderFailed,
199}
200
201#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize)]
202#[error("{path}: {code:?}: {message}")]
203pub struct CatalogBuildError {
204    pub path: LogicalContentPath,
205    pub code: CatalogBuildErrorCode,
206    pub message: Box<str>,
207}
208
209impl CatalogBuildError {
210    fn compiler(error: ContentCompilerInitializationError) -> Self {
211        Self {
212            path: LogicalContentPath::new("<content-catalog>"),
213            code: CatalogBuildErrorCode::ContentCompilerUnavailable,
214            message: error.to_string().into_boxed_str(),
215        }
216    }
217
218    fn render(error: MarkdownRenderError) -> Self {
219        Self {
220            path: error.path.clone(),
221            code: CatalogBuildErrorCode::PostRenderFailed,
222            message: error.to_string().into_boxed_str(),
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::domain::publication::PublicLedgerProjection;
231    use crate::domain::publication::PublishedPostRevision;
232    use crate::frontend_assets::embedded_manifest;
233    use crate::render::{SiteSnapshotBuildErrorCode, render_site_shell, snapshot_store};
234    use markdown_compiler::{
235        DiscoveredContentTree, LogicalAssetPath, PostCollection, SiteSnapshotDigest,
236        prepare_content,
237    };
238    use time::{Date, Month, OffsetDateTime, Time};
239
240    use crate::content_fixtures::{asset, content_tree, post, publication};
241
242    const FIRST_ID: &str = "4f054633-2d09-4b05-97d0-c6f0011a5199";
243    const SECOND_ID: &str = "7d97b17a-686d-46f4-ad77-234f4973c69a";
244
245    fn publication_source(title: &str, origins: &[&str]) -> String {
246        let origins = origins
247            .iter()
248            .map(|origin| format!("{origin:?}"))
249            .collect::<Vec<_>>()
250            .join(", ");
251        format!(
252            "[site]\n\
253             title = {title:?}\n\
254             base_url = \"https://blog.example.com/\"\n\
255             description = \"Catalog tests.\"\n\
256             [author]\n\
257             name = \"Example Author\"\n\
258             [assets]\n\
259             allowed_https_origins = [{origins}]\n"
260        )
261    }
262
263    fn post_source(id: &str, slug: &str, body: &str, draft: bool) -> String {
264        format!(
265            "+++\n\
266             id = {id:?}\n\
267             title = {slug:?}\n\
268             slug = {slug:?}\n\
269             authored_at = 2026-08-29T15:00:00-04:00\n\
270             description = \"Catalog fixture.\"\n\
271             draft = {draft}\n\
272             +++\n\
273             {body}"
274        )
275    }
276
277    fn tree(title: &str, origins: &[&str], include_second: bool) -> DiscoveredContentTree {
278        let mut posts = vec![post(
279            "drafts/first.md",
280            PostCollection::Drafts,
281            post_source(FIRST_ID, "first-post", "![cover](assets/cover.png)\n", true),
282        )];
283        if include_second {
284            posts.push(post(
285                "posts/second.md",
286                PostCollection::Posts,
287                post_source(
288                    SECOND_ID,
289                    "second-post",
290                    "[file](assets/second.pdf)\n",
291                    false,
292                ),
293            ));
294        }
295        let mut assets = vec![asset(
296            LogicalAssetPath::parse("assets/cover.png").unwrap(),
297            b"cover".to_vec(),
298        )];
299        if include_second {
300            assets.push(asset(
301                LogicalAssetPath::parse("assets/second.pdf").unwrap(),
302                b"second".to_vec(),
303            ));
304        }
305        content_tree(
306            publication("publication.toml", publication_source(title, origins)),
307            posts,
308            assets,
309            0,
310        )
311    }
312
313    fn compile_tree(tree: DiscoveredContentTree) -> ContentCatalog {
314        let content = prepare_content(&tree).unwrap();
315        compile_content_catalog(&content).unwrap()
316    }
317
318    fn compile(title: &str, origins: &[&str]) -> ContentCatalog {
319        compile_tree(tree(title, origins, false))
320    }
321
322    #[test]
323    fn compiler_clones_share_renderer_admission() {
324        let compiler = ContentCompiler::discover().expect("test renderer path must resolve");
325        let cloned = compiler.clone();
326
327        assert!(Arc::ptr_eq(&compiler.diagrams, &cloned.diagrams));
328    }
329
330    fn local_asset_reference<'assets>(
331        assets: &'assets ResolvedPostAssets,
332        path: &LogicalAssetPath,
333    ) -> &'assets DigestedAsset {
334        assets
335            .image
336            .iter()
337            .chain(assets.references.iter())
338            .find_map(|reference| match reference {
339                AssetRevisionReference::Local(asset) if &asset.path == path => Some(asset),
340                AssetRevisionReference::Local(_) | AssetRevisionReference::External(_) => None,
341            })
342            .expect("fixture reference must be present")
343    }
344
345    fn resolved_bytes<'assets>(
346        assets: &'assets ResolvedLocalAssetStore,
347        reference: &DigestedAsset,
348    ) -> &'assets [u8] {
349        assets
350            .resolve(reference)
351            .expect("fixture reference must resolve")
352            .bytes
353            .as_ref()
354    }
355
356    #[test]
357    fn catalog_owns_drafts_rendered_revisions_and_exact_local_bytes() {
358        let catalog = compile("Catalog", &[]);
359        assert_eq!(catalog.revisions.len(), 1);
360        assert!(!catalog.revisions.is_empty());
361        assert_eq!(catalog.publication.site.title.as_str(), "Catalog");
362        assert!(catalog.site_assets.allowed_origins.is_empty());
363        let (key, revision) = catalog.revisions.iter().next().unwrap();
364        let rendered = revision.rendered.as_ref();
365        let path = LogicalAssetPath::parse("assets/cover.png").unwrap();
366        let cover = local_asset_reference(&rendered.assets, &path);
367        assert_eq!(resolved_bytes(&catalog.local_assets, cover), b"cover");
368        assert_eq!(
369            rendered.document.metadata.draft,
370            markdown_compiler::DraftStatus::Draft
371        );
372        assert!(
373            catalog
374                .get(&rendered.document.metadata.id, &rendered.revision)
375                .is_some()
376        );
377        let wrong = PostRevisionDigest::parse(&format!("post-b3-v1-{}", "22".repeat(32))).unwrap();
378        assert!(catalog.get(&key.0, &wrong).is_none());
379        let wrong_id = PostId::parse(SECOND_ID).unwrap();
380        assert!(catalog.get(&wrong_id, &key.1).is_none());
381    }
382
383    #[test]
384    fn retained_revision_keeps_its_exact_render_and_local_asset_store() {
385        fn candidate(body: &str, cover: &[u8]) -> ContentCatalog {
386            compile_tree(content_tree(
387                publication("publication.toml", publication_source("Catalog", &[])),
388                vec![post(
389                    "posts/first.md",
390                    PostCollection::Posts,
391                    post_source(FIRST_ID, "first-post", body, false),
392                )],
393                vec![asset(
394                    LogicalAssetPath::parse("assets/cover.png").unwrap(),
395                    cover.to_vec(),
396                )],
397                0,
398            ))
399        }
400
401        let prior = candidate("Old body.\n\n![cover](assets/cover.png)\n", b"old cover");
402        let mut current = candidate(
403            "Current body.\n\n![cover](assets/cover.png)\n",
404            b"current cover",
405        );
406        let post_id = PostId::parse(FIRST_ID).unwrap();
407        let prior_revision = prior.current_post(&post_id).unwrap().revision.clone();
408        let current_revision = current.current_post(&post_id).unwrap().revision.clone();
409        assert_ne!(prior_revision, current_revision);
410        let ledger = PublicLedgerProjection::try_from_exact_entries([PublishedPostRevision::new(
411            post_id.clone(),
412            prior_revision.clone(),
413            time::OffsetDateTime::from_unix_timestamp(1_000).unwrap(),
414        )])
415        .unwrap();
416
417        current
418            .retain_revisions_from(&prior, ledger.revision_keys())
419            .unwrap();
420
421        assert_eq!(current.revisions.len(), 2);
422        assert_eq!(current.rendered_posts().count(), 1);
423        assert_eq!(
424            current.current_post(&post_id).unwrap().revision,
425            current_revision
426        );
427        let (retained, retained_assets) = current
428            .get_with_local_assets(&post_id, &prior_revision)
429            .unwrap();
430        assert!(retained.article.identity_html.contains("Old body."));
431        assert!(!retained.article.identity_html.contains("Current body."));
432        let cover = LogicalAssetPath::parse("assets/cover.png").unwrap();
433        let retained_cover = local_asset_reference(&retained.assets, &cover);
434        assert_eq!(
435            resolved_bytes(retained_assets, retained_cover),
436            b"old cover"
437        );
438        let current_rendered = current.current_post(&post_id).unwrap();
439        let current_cover = local_asset_reference(&current_rendered.assets, &cover);
440        assert_eq!(
441            resolved_bytes(&current.local_assets, current_cover),
442            b"current cover"
443        );
444    }
445
446    #[test]
447    fn equal_public_keys_from_different_policies_remain_candidate_scoped() {
448        let old = compile(
449            "Catalog",
450            &["https://cdn.example.com", "https://unused.example.com"],
451        );
452        let current = compile("Catalog", &["https://cdn.example.com"]);
453        let old_post = old.revisions.values().next().unwrap().rendered.as_ref();
454        let current_post = current.revisions.values().next().unwrap().rendered.as_ref();
455        assert_eq!(old_post.revision, current_post.revision);
456
457        let snapshot =
458            SiteSnapshotDigest::parse(&format!("site-b3-v1-{}", "11".repeat(32))).unwrap();
459        let error = old_post
460            .project_for_snapshot(&snapshot, &current.site_assets, &current.local_assets)
461            .unwrap_err();
462        assert_eq!(
463            error.code,
464            super::super::MarkdownRenderErrorCode::AssetPolicyMismatch
465        );
466        current_post
467            .project_for_snapshot(&snapshot, &current.site_assets, &current.local_assets)
468            .unwrap();
469    }
470
471    #[test]
472    fn preview_asset_lookup_returns_only_referenced_authored_bytes() {
473        let catalog = compile_tree(tree("Catalog", &[], false));
474        let authored = LogicalAssetPath::parse("assets/cover.png").unwrap();
475        let bytes = catalog
476            .current_preview_asset(&authored)
477            .unwrap()
478            .expect("referenced authored asset must resolve");
479        assert_eq!(bytes.as_ref(), b"cover");
480        assert!(
481            catalog
482                .current_preview_asset(&LogicalAssetPath::parse("assets/not-present.png").unwrap())
483                .unwrap()
484                .is_none()
485        );
486    }
487
488    #[test]
489    fn metadata_failure_rejects_the_candidate_without_replacing_the_active_snapshot() {
490        let mut catalog = compile_tree(tree("Catalog", &[], true));
491        let post_id = PostId::parse(SECOND_ID).unwrap();
492        let revision = catalog.current_post(&post_id).unwrap().revision.clone();
493        let ledger = PublicLedgerProjection::try_from_exact_entries([PublishedPostRevision::new(
494            post_id.clone(),
495            revision.clone(),
496            OffsetDateTime::from_unix_timestamp(2_000).unwrap(),
497        )])
498        .unwrap();
499        let valid_shell =
500            render_site_shell(Arc::new(catalog.clone()), embedded_manifest(), &ledger).unwrap();
501        let active = valid_shell.into_snapshot().unwrap();
502        let (reader, _activator) = snapshot_store(active);
503        let before = reader.load_full();
504
505        let retained = catalog
506            .revisions
507            .get_mut(&(post_id.clone(), revision))
508            .unwrap();
509        Arc::make_mut(&mut retained.rendered)
510            .document
511            .metadata
512            .authored_at = Date::from_calendar_date(-1, Month::January, 1)
513            .unwrap()
514            .with_time(Time::MIDNIGHT)
515            .assume_utc();
516        let error = render_site_shell(Arc::new(catalog), embedded_manifest(), &ledger).unwrap_err();
517
518        assert_eq!(error.code, SiteSnapshotBuildErrorCode::MetadataRenderFailed);
519        assert_eq!(error.post_id.as_ref(), Some(&post_id));
520        assert!(Arc::ptr_eq(&before, &reader.load_full()));
521    }
522
523    #[test]
524    fn every_catalog_error_code_has_a_stable_wire_value() {
525        for (value, expected) in [
526            (
527                CatalogBuildErrorCode::ContentCompilerUnavailable,
528                "content_compiler_unavailable",
529            ),
530            (
531                CatalogBuildErrorCode::PostRenderFailed,
532                "post_render_failed",
533            ),
534        ] {
535            assert_eq!(serde_json::to_value(value).unwrap(), expected);
536        }
537    }
538}