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
19pub 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#[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 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#[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 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 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 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 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\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\n", b"old cover");
402 let mut current = candidate(
403 "Current body.\n\n\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(¤t_rendered.assets, &cover);
440 assert_eq!(
441 resolved_bytes(¤t.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, ¤t.site_assets, ¤t.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, ¤t.site_assets, ¤t.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}