1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fmt::{self, Write as _},
4 sync::Arc,
5};
6
7use arc_swap::ArcSwap;
8use markdown_compiler::identity::{
9 PreviewDigestInput, PublishedPostIdentityInput, SiteShellOutputDigest, SiteShellOutputHasher,
10 finalize_preview_digest, finalize_site_snapshot,
11};
12use markdown_compiler::{
13 AssetDigest, AssetRevisionReference, DefaultPostTipPolicy, DigestedAsset, DraftStatus,
14 LogicalAssetPath, PostAlias, PostDescription, PostId, PostRevisionDigest, PostSlug, PostTag,
15 PostTipPolicy, PostTitle, PreviewDigest, PublicationSettings, ResolvedLocalAssetStore,
16 ResolvedPostAssets, ResolvedSiteAssets, RevisionIdentityError, SiteShellRendererIdentity,
17 SiteSnapshotDigest,
18};
19use maud::{DOCTYPE, Markup, PreEscaped, html};
20use qrcode::{QrCode, types::Color};
21use serde::Serialize;
22use thiserror::Error;
23use time::OffsetDateTime;
24
25use crate::domain::profile::TipRecipientProjection;
26use crate::domain::publication::{
27 CanonicalSiteUrl, MAX_PUBLIC_ROUTES, PublicLedgerProjection, PublicPagePath,
28 PublishedPostRevision, assets::AssetDelivery,
29};
30use crate::frontend_assets::FrontendAssetManifest;
31
32use super::metadata::{
33 MetadataRenderError, PostHeadMetadataInput, RenderedPostHeadMetadata, render_post_head_metadata,
34};
35use super::policy::{PublicResponsePolicy, REFERRER_POLICY};
36use super::robots::{RenderedRobots, RobotsRenderError, render_robots};
37use super::rss::{RenderedRssFeed, RssItem, RssRenderError, render_rss};
38use super::sitemap::{RenderedSitemap, SitemapRenderError, render_sitemap};
39use super::{ContentCatalog, RenderedPost, SnapshotAssetPath};
40
41const MAX_PAGE_BYTES: usize = 40 * 1024 * 1024;
42const MAX_RETAINED_HTML_BYTES: usize = 512 * 1024 * 1024;
43const MAX_PUBLIC_ASSETS: usize = 50_000;
44const MAX_RETAINED_ASSET_BYTES: usize = 512 * 1024 * 1024;
45const FIXED_PUBLIC_ROUTES: usize = 7;
47
48#[cfg(test)]
49fn render_post_preview(
50 catalog: &ContentCatalog,
51 frontend: &'static FrontendAssetManifest,
52 post_id: &PostId,
53 preview_asset_endpoint: &str,
54 published_at: Option<OffsetDateTime>,
55) -> Result<Option<String>, SiteSnapshotBuildError> {
56 render_bound_post_preview(
57 catalog,
58 frontend,
59 post_id,
60 None,
61 preview_asset_endpoint,
62 published_at,
63 )
64 .map(|preview| preview.map(|preview| preview.html))
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68struct PublicPostView {
69 post_id: PostId,
70 revision: PostRevisionDigest,
71 title: PostTitle,
72 slug: PostSlug,
73 description: PostDescription,
74 tags: Arc<[PostTag]>,
75 aliases: Arc<[PostAlias]>,
76 authored_at: OffsetDateTime,
77 updated_at: Option<OffsetDateTime>,
78 published_at: OffsetDateTime,
79 canonical_url: Arc<CanonicalSiteUrl>,
80 tips: PostTipPolicy,
81 image: Option<AssetRevisionReference>,
82}
83
84impl PublicPostView {
85 fn from_rendered(
86 rendered: &RenderedPost,
87 entry: &PublishedPostRevision,
88 publication: &PublicationSettings,
89 ) -> Self {
90 let metadata = &rendered.document.metadata;
91 let path = PublicPagePath::post(&metadata.slug);
92 Self {
93 post_id: metadata.id.clone(),
94 revision: rendered.revision.clone(),
95 title: metadata.title.clone(),
96 slug: metadata.slug.clone(),
97 description: metadata.description.clone(),
98 tags: Arc::from(metadata.tags.as_slice()),
99 aliases: Arc::from(metadata.aliases.as_slice()),
100 authored_at: metadata.authored_at,
101 updated_at: metadata.updated_at,
102 published_at: entry.published_at,
103 canonical_url: Arc::new(CanonicalSiteUrl::for_path(
104 &publication.site.base_url,
105 &path,
106 )),
107 tips: metadata.tips,
108 image: rendered.assets.image.clone(),
109 }
110 }
111
112 fn public_path(&self) -> PublicPagePath {
113 PublicPagePath::post(&self.slug)
114 }
115}
116
117pub struct RenderedSiteShell {
122 catalog: Arc<ContentCatalog>,
123 frontend: &'static FrontendAssetManifest,
124 ledger: PublicLedgerProjection,
125 renderer: SiteShellRendererIdentity,
126 posts: Arc<[PublicPostView]>,
127 chronology: Arc<[usize]>,
128 post_navigation: Arc<[ChronologicalNeighbors]>,
129 tags: BTreeMap<PostTag, Arc<[usize]>>,
130 redirects: BTreeMap<PostAlias, Arc<CanonicalSiteUrl>>,
131 feed: RenderedRssFeed,
132 robots: RenderedRobots,
133 sitemap: RenderedSitemap,
134 pre_injection_output: SiteShellOutputDigest,
135 tip_recipient: Option<TipRecipientProjection>,
136}
137
138impl fmt::Debug for RenderedSiteShell {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 formatter
141 .debug_struct("RenderedSiteShell")
142 .field("ledger_entries", &self.ledger.len())
143 .field("posts", &self.posts.len())
144 .field("tags", &self.tags.len())
145 .field("redirects", &self.redirects.len())
146 .field("feed_digest", &self.feed.digest)
147 .field("robots_digest", &self.robots.digest)
148 .field("sitemap_digest", &self.sitemap.digest)
149 .finish_non_exhaustive()
150 }
151}
152
153pub fn render_site_shell(
154 catalog: Arc<ContentCatalog>,
155 frontend: &'static FrontendAssetManifest,
156 ledger: &PublicLedgerProjection,
157) -> Result<RenderedSiteShell, SiteSnapshotBuildError> {
158 frontend
159 .validate()
160 .map_err(|error| SiteSnapshotBuildError::frontend(error.to_string()))?;
161 let posts = select_public_posts(&catalog, ledger)?;
162 let chronology = chronology(&posts);
163 let post_navigation = chronological_neighbors(posts.len(), &chronology);
164 let tags = tag_index(&posts, &chronology);
165 let alias_count = posts
166 .iter()
167 .try_fold(0_usize, |count, post| count.checked_add(post.aliases.len()));
168 let alias_count = alias_count.ok_or_else(SiteSnapshotBuildError::route_limit)?;
169 validate_route_count(posts.len(), tags.len(), alias_count)?;
170 let redirects = alias_redirect_index(&posts)?;
171 let feed = render_public_feed(&catalog.publication, &posts, &chronology)?;
172 let sitemap = render_public_sitemap(&catalog.publication, &posts, &tags)?;
173 let robots = render_public_robots(&catalog.publication)?;
174
175 let renderer = SiteShellRendererIdentity::new(*frontend.bundle_digest.as_bytes());
176 let pre_injection_output = render_pre_injection_shell(
177 &PageRenderer::new(
178 &catalog.publication,
179 frontend,
180 &catalog.site_assets,
181 HeadAssetProjection::Identity,
182 )?,
183 PublicPagePlan {
184 posts: &posts,
185 chronology: &chronology,
186 post_navigation: &post_navigation,
187 tags: &tags,
188 redirects: &redirects,
189 },
190 DiscoveryDocuments {
191 feed: &feed,
192 robots: &robots,
193 sitemap: &sitemap,
194 },
195 )?;
196
197 Ok(RenderedSiteShell {
198 catalog,
199 frontend,
200 ledger: ledger.clone(),
201 renderer,
202 posts: posts.into(),
203 chronology: chronology.into(),
204 post_navigation: post_navigation.into(),
205 tags,
206 redirects,
207 feed,
208 robots,
209 sitemap,
210 pre_injection_output,
211 tip_recipient: None,
212 })
213}
214
215impl RenderedSiteShell {
216 pub(crate) fn bind_tip_recipient(mut self, recipient: Option<TipRecipientProjection>) -> Self {
217 self.tip_recipient = recipient;
218 self
219 }
220}
221
222pub(crate) struct BoundPostPreview {
224 pub(crate) html: String,
225 pub(crate) digest: PreviewDigest,
226 pub(crate) revision: PostRevisionDigest,
227 pub(crate) canonical_url: CanonicalSiteUrl,
228}
229
230pub(crate) fn render_bound_post_preview(
232 catalog: &ContentCatalog,
233 frontend: &'static FrontendAssetManifest,
234 post_id: &PostId,
235 tip_recipient: Option<&TipRecipientProjection>,
236 preview_asset_endpoint: &str,
237 published_at: Option<OffsetDateTime>,
238) -> Result<Option<BoundPostPreview>, SiteSnapshotBuildError> {
239 let Some(rendered) = catalog.current_post(post_id) else {
240 return Ok(None);
241 };
242 render_bound_preview(
243 catalog,
244 frontend,
245 rendered,
246 tip_recipient,
247 catalog.local_assets.as_ref(),
248 preview_asset_endpoint,
249 published_at,
250 )
251 .map(Some)
252}
253
254pub(crate) fn render_bound_post_revision_preview(
256 catalog: &ContentCatalog,
257 frontend: &'static FrontendAssetManifest,
258 post_id: &PostId,
259 revision: &PostRevisionDigest,
260 tip_recipient: Option<&TipRecipientProjection>,
261 preview_asset_endpoint: &str,
262 published_at: Option<OffsetDateTime>,
263) -> Result<Option<BoundPostPreview>, SiteSnapshotBuildError> {
264 let Some((rendered, local_assets)) = catalog.get_with_local_assets(post_id, revision) else {
265 return Ok(None);
266 };
267 render_bound_preview(
268 catalog,
269 frontend,
270 rendered,
271 tip_recipient,
272 local_assets,
273 preview_asset_endpoint,
274 published_at,
275 )
276 .map(Some)
277}
278
279fn render_bound_preview(
280 catalog: &ContentCatalog,
281 frontend: &'static FrontendAssetManifest,
282 rendered: &RenderedPost,
283 tip_recipient: Option<&TipRecipientProjection>,
284 local_assets: &ResolvedLocalAssetStore,
285 preview_asset_endpoint: &str,
286 published_at: Option<OffsetDateTime>,
287) -> Result<BoundPostPreview, SiteSnapshotBuildError> {
288 frontend
289 .validate()
290 .map_err(|error| SiteSnapshotBuildError::frontend(error.to_string()))?;
291 let post_id = &rendered.document.metadata.id;
292 let article = rendered
293 .project_for_preview(preview_asset_endpoint, &catalog.site_assets, local_assets)
294 .map(ProjectedArticleHtml::new)
295 .map_err(|error| {
296 SiteSnapshotBuildError::post(
297 SiteSnapshotBuildErrorCode::ArticleProjectionFailed,
298 post_id.clone(),
299 error.to_string(),
300 )
301 })?;
302 let canonical_url = CanonicalSiteUrl::for_path(
303 &catalog.publication.site.base_url,
304 &PublicPagePath::post(&rendered.document.metadata.slug),
305 );
306 let renderer = &PageRenderer::new(
307 &catalog.publication,
308 frontend,
309 &catalog.site_assets,
310 HeadAssetProjection::Preview(preview_asset_endpoint),
311 )?;
312 let page = PostPageView::from_rendered(rendered, published_at);
313 let tips_enabled = page.tips_enabled(&catalog.publication);
314 let tip_handoff = if tips_enabled {
315 tip_recipient.map(TipHandoff::new).transpose()?
316 } else {
317 None
318 };
319 let html = render_post(
320 renderer,
321 page,
322 &canonical_url,
323 ArticleBody::Projected(&article),
324 PostNavigation::default(),
325 tip_handoff.as_ref(),
326 )?
327 .into_string();
328 validate_page_size(html.len())?;
329 let renderer = &PageRenderer::new(
330 &catalog.publication,
331 frontend,
332 &catalog.site_assets,
333 HeadAssetProjection::Identity,
334 )?;
335 let pre_injection_shell = render_post(
336 renderer,
337 PostPageView::from_rendered(rendered, None),
338 &canonical_url,
339 ArticleBody::Omitted,
340 PostNavigation::default(),
341 None,
342 )?
343 .into_string();
344 validate_page_size(pre_injection_shell.len())?;
345 let site_renderer = SiteShellRendererIdentity::new(*frontend.bundle_digest.as_bytes());
346 let profile_projection = if tips_enabled {
347 tip_recipient
348 .map(TipRecipientProjection::identity_bytes)
349 .unwrap_or_default()
350 } else {
351 Vec::new()
352 };
353 let digest = finalize_preview_digest(PreviewDigestInput {
354 publication: &catalog.publication,
355 site_assets: &catalog.site_assets,
356 post_id,
357 post_revision: &rendered.revision,
358 post_renderer: &rendered.renderer,
359 article_identity_html: rendered.article.identity_html.as_bytes(),
360 site_renderer: &site_renderer,
361 pre_injection_post_shell: pre_injection_shell.as_bytes(),
362 response_policy: renderer.policy.content_security_policy.as_bytes(),
363 profile_projection: &profile_projection,
364 canonical_url: canonical_url.as_str(),
365 })
366 .map_err(SiteSnapshotBuildError::identity)?;
367 Ok(BoundPostPreview {
368 html,
369 digest,
370 revision: rendered.revision.clone(),
371 canonical_url,
372 })
373}
374
375fn select_public_posts(
376 catalog: &ContentCatalog,
377 ledger: &PublicLedgerProjection,
378) -> Result<Vec<PublicPostView>, SiteSnapshotBuildError> {
379 let mut posts = Vec::with_capacity(ledger.len());
380 let mut known_route_count = FIXED_PUBLIC_ROUTES;
381 for entry in ledger.published_posts() {
382 let Some(rendered) = catalog.get(&entry.post_id, &entry.revision) else {
383 return Err(SiteSnapshotBuildError::post(
384 SiteSnapshotBuildErrorCode::RevisionUnavailable,
385 entry.post_id.clone(),
386 "the exact public-ledger revision is not available in this catalog",
387 ));
388 };
389 if rendered.document.metadata.draft == DraftStatus::Draft {
390 return Err(SiteSnapshotBuildError::post(
391 SiteSnapshotBuildErrorCode::DraftSelected,
392 entry.post_id.clone(),
393 "the public ledger selected a draft revision",
394 ));
395 }
396 known_route_count = known_route_count
397 .checked_add(1)
398 .and_then(|count| count.checked_add(rendered.document.metadata.aliases.len()))
399 .filter(|count| *count <= MAX_PUBLIC_ROUTES)
400 .ok_or_else(SiteSnapshotBuildError::route_limit)?;
401 posts.push(PublicPostView::from_rendered(
402 rendered,
403 entry,
404 &catalog.publication,
405 ));
406 }
407 Ok(posts)
408}
409
410fn chronology(posts: &[PublicPostView]) -> Vec<usize> {
411 let mut chronology: Vec<_> = (0..posts.len()).collect();
412 chronology.sort_by(|left, right| {
413 posts[*right]
414 .published_at
415 .cmp(&posts[*left].published_at)
416 .then_with(|| posts[*left].post_id.cmp(&posts[*right].post_id))
417 });
418 chronology
419}
420
421#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
422struct ChronologicalNeighbors {
423 previous: Option<usize>,
424 next: Option<usize>,
425}
426
427fn chronological_neighbors(post_count: usize, chronology: &[usize]) -> Vec<ChronologicalNeighbors> {
428 let mut neighbors = vec![ChronologicalNeighbors::default(); post_count];
429 for (position, post_index) in chronology.iter().copied().enumerate() {
430 neighbors[post_index] = ChronologicalNeighbors {
431 next: position
432 .checked_sub(1)
433 .and_then(|next| chronology.get(next).copied()),
434 previous: chronology.get(position + 1).copied(),
435 };
436 }
437 neighbors
438}
439
440fn tag_index(posts: &[PublicPostView], chronology: &[usize]) -> BTreeMap<PostTag, Arc<[usize]>> {
441 let mut tags: BTreeMap<PostTag, Vec<usize>> = BTreeMap::new();
442 for post_index in chronology {
443 for tag in &*posts[*post_index].tags {
444 tags.entry(tag.clone()).or_default().push(*post_index);
445 }
446 }
447 tags.into_iter()
448 .map(|(tag, posts)| (tag, posts.into()))
449 .collect()
450}
451
452fn alias_redirect_index(
453 posts: &[PublicPostView],
454) -> Result<BTreeMap<PostAlias, Arc<CanonicalSiteUrl>>, SiteSnapshotBuildError> {
455 let canonical_slugs: BTreeSet<_> = posts.iter().map(|post| post.slug.as_str()).collect();
456 let mut redirects = BTreeMap::new();
457 for post in posts {
458 for alias in &*post.aliases {
459 if canonical_slugs.contains(alias.as_str())
460 || redirects
461 .insert(alias.clone(), Arc::clone(&post.canonical_url))
462 .is_some()
463 {
464 return Err(SiteSnapshotBuildError::post(
465 SiteSnapshotBuildErrorCode::RouteCollision,
466 post.post_id.clone(),
467 format!(
468 "published alias {} conflicts with another public post route",
469 alias.as_str()
470 ),
471 ));
472 }
473 }
474 }
475 Ok(redirects)
476}
477
478fn render_public_feed(
479 publication: &PublicationSettings,
480 posts: &[PublicPostView],
481 chronology: &[usize],
482) -> Result<RenderedRssFeed, SiteSnapshotBuildError> {
483 let feed_url = CanonicalSiteUrl::for_path(&publication.site.base_url, &PublicPagePath::feed());
484 render_rss(
485 publication,
486 &feed_url,
487 chronology.iter().map(|index| {
488 let post = &posts[*index];
489 RssItem {
490 post_id: &post.post_id,
491 title: &post.title,
492 description: &post.description,
493 canonical_url: &post.canonical_url,
494 published_at: post.published_at,
495 }
496 }),
497 )
498 .map_err(SiteSnapshotBuildError::rss)
499}
500
501fn render_public_sitemap(
502 publication: &PublicationSettings,
503 posts: &[PublicPostView],
504 tags: &BTreeMap<PostTag, Arc<[usize]>>,
505) -> Result<RenderedSitemap, SiteSnapshotBuildError> {
506 let locations: Vec<_> = std::iter::once(PublicPagePath::index())
507 .chain(std::iter::once(PublicPagePath::archive()))
508 .chain(posts.iter().map(PublicPostView::public_path))
509 .chain(tags.keys().map(PublicPagePath::tag))
510 .map(|path| CanonicalSiteUrl::for_path(&publication.site.base_url, &path))
511 .collect();
512 render_sitemap(&locations).map_err(SiteSnapshotBuildError::sitemap)
513}
514
515fn render_public_robots(
516 publication: &PublicationSettings,
517) -> Result<RenderedRobots, SiteSnapshotBuildError> {
518 let sitemap_url =
519 CanonicalSiteUrl::for_path(&publication.site.base_url, &PublicPagePath::sitemap());
520 render_robots(&sitemap_url).map_err(SiteSnapshotBuildError::robots)
521}
522
523fn validate_route_count(
524 posts: usize,
525 tags: usize,
526 aliases: usize,
527) -> Result<(), SiteSnapshotBuildError> {
528 let routes = posts
529 .checked_add(tags)
530 .and_then(|count| count.checked_add(aliases))
531 .and_then(|count| count.checked_add(FIXED_PUBLIC_ROUTES))
532 .ok_or_else(SiteSnapshotBuildError::route_limit)?;
533 if routes > MAX_PUBLIC_ROUTES {
534 return Err(SiteSnapshotBuildError::route_limit());
535 }
536 Ok(())
537}
538
539#[derive(Clone, Copy)]
540struct PublicPagePlan<'plan> {
541 posts: &'plan [PublicPostView],
542 chronology: &'plan [usize],
543 post_navigation: &'plan [ChronologicalNeighbors],
544 tags: &'plan BTreeMap<PostTag, Arc<[usize]>>,
545 redirects: &'plan BTreeMap<PostAlias, Arc<CanonicalSiteUrl>>,
546}
547
548fn render_pre_injection_shell(
549 renderer: &PageRenderer<'_>,
550 plan: PublicPagePlan<'_>,
551 discovery: DiscoveryDocuments<'_>,
552) -> Result<SiteShellOutputDigest, SiteSnapshotBuildError> {
553 let mut pages = BTreeMap::new();
554 pages.insert(PublicPagePath::index(), PreInjectionPage::Index);
555 pages.insert(PublicPagePath::archive(), PreInjectionPage::Archive);
556 pages.insert(PublicPagePath::feed(), PreInjectionPage::Feed);
557 pages.insert(PublicPagePath::robots(), PreInjectionPage::Robots);
558 pages.insert(PublicPagePath::sitemap(), PreInjectionPage::Sitemap);
559 for (index, post) in plan.posts.iter().enumerate() {
560 pages.insert(post.public_path(), PreInjectionPage::Post(index));
561 }
562 for tag in plan.tags.keys() {
563 pages.insert(PublicPagePath::tag(tag), PreInjectionPage::Tag(tag));
564 }
565 for (alias, target) in plan.redirects {
566 pages.insert(
567 PublicPagePath::post_alias(alias),
568 PreInjectionPage::Redirect(target.as_ref()),
569 );
570 }
571 pages.insert(
572 PublicPagePath::error_identity_marker("not-found"),
573 PreInjectionPage::Error(PublicErrorPage::NotFound),
574 );
575 pages.insert(
576 PublicPagePath::error_identity_marker("method-not-allowed"),
577 PreInjectionPage::Error(PublicErrorPage::MethodNotAllowed),
578 );
579
580 let mut retained = RetainedHtmlBudget::new();
581 let mut hasher = SiteShellOutputHasher::new(pages.len() + 2);
582 hasher.page(
583 "/maincopy-identity/public-response-policy",
584 renderer.policy.content_security_policy.as_bytes(),
585 );
586 hasher.page(
587 "/maincopy-identity/referrer-policy",
588 REFERRER_POLICY.as_bytes(),
589 );
590 for (path, page) in pages {
591 match page {
592 PreInjectionPage::Index => hash_pre_injection_html(
593 &mut hasher,
594 &mut retained,
595 &path,
596 render_index(renderer, plan.posts, plan.chronology).into_string(),
597 ),
598 PreInjectionPage::Archive => hash_pre_injection_html(
599 &mut hasher,
600 &mut retained,
601 &path,
602 render_archive(renderer, plan.posts, plan.chronology).into_string(),
603 ),
604 PreInjectionPage::Feed => {
605 hasher.page(path.as_str(), discovery.feed.body.as_bytes());
606 Ok(())
607 }
608 PreInjectionPage::Robots => {
609 hasher.page(path.as_str(), discovery.robots.body.as_bytes());
610 Ok(())
611 }
612 PreInjectionPage::Sitemap => {
613 hasher.page(path.as_str(), discovery.sitemap.body.as_bytes());
614 Ok(())
615 }
616 PreInjectionPage::Post(index) => hash_pre_injection_html(
617 &mut hasher,
618 &mut retained,
619 &path,
620 render_post(
621 renderer,
622 PostPageView::from_public(&plan.posts[index]),
623 &plan.posts[index].canonical_url,
624 ArticleBody::Omitted,
625 PostNavigation::from_indexes(plan.posts, plan.post_navigation[index]),
626 None,
627 )?
628 .into_string(),
629 ),
630 PreInjectionPage::Tag(tag) => hash_pre_injection_html(
631 &mut hasher,
632 &mut retained,
633 &path,
634 render_tag(
635 renderer,
636 tag,
637 plan.posts,
638 plan.tags.get(tag).map_or(&[], Arc::as_ref),
639 )
640 .into_string(),
641 ),
642 PreInjectionPage::Redirect(target) => {
643 hasher.page(path.as_str(), target.as_str().as_bytes());
644 Ok(())
645 }
646 PreInjectionPage::Error(error) => hash_pre_injection_html(
647 &mut hasher,
648 &mut retained,
649 &path,
650 render_error(renderer, error).into_string(),
651 ),
652 }?;
653 }
654 Ok(hasher.finish())
655}
656
657#[derive(Clone, Copy)]
658struct DiscoveryDocuments<'documents> {
659 feed: &'documents RenderedRssFeed,
660 robots: &'documents RenderedRobots,
661 sitemap: &'documents RenderedSitemap,
662}
663
664fn hash_pre_injection_html(
665 hasher: &mut SiteShellOutputHasher,
666 retained: &mut RetainedHtmlBudget,
667 path: &PublicPagePath,
668 html: String,
669) -> Result<(), SiteSnapshotBuildError> {
670 validate_page_size(html.len())?;
671 retained.add(html.len())?;
672 hasher.page(path.as_str(), html.as_bytes());
673 Ok(())
674}
675
676#[derive(Clone, Copy)]
677enum PreInjectionPage<'view> {
678 Index,
679 Archive,
680 Feed,
681 Robots,
682 Sitemap,
683 Post(usize),
684 Tag(&'view PostTag),
685 Redirect(&'view CanonicalSiteUrl),
686 Error(PublicErrorPage),
687}
688
689impl RenderedSiteShell {
690 pub fn into_snapshot(self) -> Result<SiteSnapshot, SiteSnapshotBuildError> {
692 let public_posts: Vec<_> = self
693 .ledger
694 .published_posts()
695 .map(|published| {
696 PublishedPostIdentityInput::new(
697 &published.post_id,
698 &published.revision,
699 published.published_at,
700 )
701 })
702 .collect();
703 let digest = finalize_site_snapshot(
704 &self.catalog.publication,
705 &self.catalog.site_assets,
706 &self.renderer,
707 &self.pre_injection_output,
708 &public_posts,
709 )
710 .map_err(SiteSnapshotBuildError::identity)?;
711
712 let mut retained = RetainedHtmlBudget::new();
713 let tip_handoff = self
714 .tip_recipient
715 .as_ref()
716 .map(TipHandoff::new)
717 .transpose()?;
718 let pages = render_snapshot_pages(&self, &digest, tip_handoff.as_ref(), &mut retained)?;
719 let renderer = &PageRenderer::new(
720 &self.catalog.publication,
721 self.frontend,
722 &self.catalog.site_assets,
723 HeadAssetProjection::Snapshot(&digest),
724 )?;
725 let not_found = rendered_error_page(renderer, PublicErrorPage::NotFound, &mut retained)?;
726 let method_not_allowed =
727 rendered_error_page(renderer, PublicErrorPage::MethodNotAllowed, &mut retained)?;
728 let assets = collect_public_assets(&self, &digest)?;
729 let feed = self.feed;
730 let robots = self.robots;
731 let sitemap = self.sitemap;
732 let redirects = self.redirects;
733 let presentation_digest = presentation_digest(
734 &pages,
735 &redirects,
736 ¬_found,
737 &method_not_allowed,
738 &feed,
739 &robots,
740 &sitemap,
741 );
742
743 let response_policy = renderer.policy.clone();
744 Ok(SiteSnapshot {
745 digest,
746 presentation_digest,
747 feed,
748 robots,
749 sitemap,
750 pages,
751 redirects,
752 not_found,
753 method_not_allowed,
754 assets,
755 response_policy,
756 frontend: self.frontend,
757 retained_html_bytes: retained.used,
758 })
759 }
760}
761
762fn render_snapshot_pages(
763 shell: &RenderedSiteShell,
764 digest: &SiteSnapshotDigest,
765 tip_handoff: Option<&TipHandoff<'_>>,
766 retained: &mut RetainedHtmlBudget,
767) -> Result<BTreeMap<PageRoute, RenderedPage>, SiteSnapshotBuildError> {
768 let publication = &shell.catalog.publication;
769 let renderer = &PageRenderer::new(
770 publication,
771 shell.frontend,
772 &shell.catalog.site_assets,
773 HeadAssetProjection::Snapshot(digest),
774 )?;
775 let mut pages = BTreeMap::new();
776
777 insert_page(
778 &mut pages,
779 PageRoute::Index,
780 render_index(renderer, &shell.posts, &shell.chronology).into_string(),
781 publication,
782 retained,
783 )?;
784 insert_page(
785 &mut pages,
786 PageRoute::Archive,
787 render_archive(renderer, &shell.posts, &shell.chronology).into_string(),
788 publication,
789 retained,
790 )?;
791
792 for (post_index, post) in shell.posts.iter().enumerate() {
793 let (rendered, local_assets) = shell
794 .catalog
795 .get_with_local_assets(&post.post_id, &post.revision)
796 .ok_or_else(|| {
797 SiteSnapshotBuildError::post(
798 SiteSnapshotBuildErrorCode::RevisionUnavailable,
799 post.post_id.clone(),
800 "the bound post revision disappeared before snapshot projection",
801 )
802 })?;
803 let article = rendered
804 .project_for_snapshot(digest, &shell.catalog.site_assets, local_assets)
805 .map(ProjectedArticleHtml::new)
806 .map_err(|error| {
807 SiteSnapshotBuildError::post(
808 SiteSnapshotBuildErrorCode::ArticleProjectionFailed,
809 post.post_id.clone(),
810 error.to_string(),
811 )
812 })?;
813 insert_page(
814 &mut pages,
815 PageRoute::Post(post.slug.clone()),
816 render_post(
817 renderer,
818 PostPageView::from_public(post),
819 &post.canonical_url,
820 ArticleBody::Projected(&article),
821 PostNavigation::from_indexes(&shell.posts, shell.post_navigation[post_index]),
822 tip_handoff,
823 )?
824 .into_string(),
825 publication,
826 retained,
827 )?;
828 }
829 for (tag, indexes) in &shell.tags {
830 insert_page(
831 &mut pages,
832 PageRoute::Tag(tag.clone()),
833 render_tag(renderer, tag, &shell.posts, indexes).into_string(),
834 publication,
835 retained,
836 )?;
837 }
838 Ok(pages)
839}
840
841fn insert_page(
842 pages: &mut BTreeMap<PageRoute, RenderedPage>,
843 route: PageRoute,
844 html: String,
845 publication: &PublicationSettings,
846 retained: &mut RetainedHtmlBudget,
847) -> Result<(), SiteSnapshotBuildError> {
848 validate_page_size(html.len())?;
849 retained.add(html.len())?;
850 let path = route.public_path();
851 let page = RenderedPage {
852 html: html.into(),
853 canonical_url: CanonicalSiteUrl::for_path(&publication.site.base_url, &path),
854 };
855 if pages.insert(route, page).is_some() {
856 return Err(SiteSnapshotBuildError::new(
857 SiteSnapshotBuildErrorCode::RouteCollision,
858 None,
859 "two public pages resolved to the same typed route",
860 ));
861 }
862 Ok(())
863}
864
865fn rendered_error_page(
866 renderer: &PageRenderer<'_>,
867 error: PublicErrorPage,
868 retained: &mut RetainedHtmlBudget,
869) -> Result<RenderedPage, SiteSnapshotBuildError> {
870 let publication = renderer.publication;
871 let html = render_error(renderer, error).into_string();
872 validate_page_size(html.len())?;
873 retained.add(html.len())?;
874 Ok(RenderedPage {
875 html: html.into(),
876 canonical_url: CanonicalSiteUrl::for_path(
877 &publication.site.base_url,
878 &PublicPagePath::index(),
879 ),
880 })
881}
882
883fn validate_page_size(bytes: usize) -> Result<(), SiteSnapshotBuildError> {
884 if bytes > MAX_PAGE_BYTES {
885 return Err(SiteSnapshotBuildError::new(
886 SiteSnapshotBuildErrorCode::PageLimitExceeded,
887 None,
888 format!("rendered page exceeds the inclusive {MAX_PAGE_BYTES}-byte limit"),
889 ));
890 }
891 Ok(())
892}
893
894struct RetainedHtmlBudget {
895 used: usize,
896}
897
898impl RetainedHtmlBudget {
899 const fn new() -> Self {
900 Self { used: 0 }
901 }
902
903 fn add(&mut self, bytes: usize) -> Result<(), SiteSnapshotBuildError> {
904 let Some(next) = self.used.checked_add(bytes) else {
905 return Err(SiteSnapshotBuildError::retained_html_limit());
906 };
907 if next > MAX_RETAINED_HTML_BYTES {
908 return Err(SiteSnapshotBuildError::retained_html_limit());
909 }
910 self.used = next;
911 Ok(())
912 }
913}
914
915fn collect_public_assets(
916 shell: &RenderedSiteShell,
917 digest: &SiteSnapshotDigest,
918) -> Result<BTreeMap<SnapshotAssetPath, SnapshotPublicAsset>, SiteSnapshotBuildError> {
919 let mut selected = SelectedAssets::new();
920 collect_site_global_assets(
921 &mut selected,
922 &shell.catalog.site_assets,
923 &shell.catalog.local_assets,
924 )?;
925
926 for post in &*shell.posts {
927 let (rendered, local_assets) = shell
928 .catalog
929 .get_with_local_assets(&post.post_id, &post.revision)
930 .ok_or_else(|| {
931 SiteSnapshotBuildError::post(
932 SiteSnapshotBuildErrorCode::RevisionUnavailable,
933 post.post_id.clone(),
934 "the selected post revision is unavailable while collecting assets",
935 )
936 })?;
937 collect_selected_post_assets(&mut selected, &rendered.assets, local_assets)?;
938 }
939
940 materialize_public_assets(selected, digest)
941}
942
943fn collect_site_global_assets(
944 selected: &mut SelectedAssets,
945 site_assets: &ResolvedSiteAssets,
946 local_assets: &ResolvedLocalAssetStore,
947) -> Result<(), SiteSnapshotBuildError> {
948 if let Some(AssetRevisionReference::Local(asset)) = &site_assets.favicon {
949 insert_authored_asset(selected, asset, local_assets)?;
950 }
951 for reference in site_assets.image.iter().chain(&site_assets.references) {
952 if let AssetRevisionReference::Local(asset) = reference {
953 insert_authored_asset(selected, asset, local_assets)?;
954 }
955 }
956 Ok(())
957}
958
959fn collect_selected_post_assets(
960 selected: &mut SelectedAssets,
961 assets: &ResolvedPostAssets,
962 store: &ResolvedLocalAssetStore,
963) -> Result<(), SiteSnapshotBuildError> {
964 if let Some(AssetRevisionReference::Local(asset)) = &assets.image {
965 insert_authored_asset(selected, asset, store)?;
966 }
967 for reference in &assets.references {
968 if let AssetRevisionReference::Local(asset) = reference {
969 insert_authored_asset(selected, asset, store)?;
970 }
971 }
972 Ok(())
973}
974
975fn materialize_public_assets(
976 selected: SelectedAssets,
977 digest: &SiteSnapshotDigest,
978) -> Result<BTreeMap<SnapshotAssetPath, SnapshotPublicAsset>, SiteSnapshotBuildError> {
979 selected
982 .by_path
983 .into_values()
984 .map(|selected| {
985 let delivery = AssetDelivery::for_authored(&selected.asset.path);
986 let path = SnapshotAssetPath::new(digest, &selected.asset.path).map_err(|error| {
987 SiteSnapshotBuildError::new(
988 SiteSnapshotBuildErrorCode::AssetUnavailable,
989 None,
990 error.to_string(),
991 )
992 })?;
993 let public = SnapshotPublicAsset {
994 digest: selected.asset.digest,
995 bytes: selected.bytes,
996 delivery,
997 };
998 Ok((path, public))
999 })
1000 .collect()
1001}
1002
1003struct SelectedAsset {
1004 asset: DigestedAsset,
1005 bytes: Arc<[u8]>,
1006}
1007
1008struct SelectedAssets {
1009 by_path: BTreeMap<LogicalAssetPath, SelectedAsset>,
1010 retained_bytes: usize,
1011}
1012
1013impl SelectedAssets {
1014 const fn new() -> Self {
1015 Self {
1016 by_path: BTreeMap::new(),
1017 retained_bytes: 0,
1018 }
1019 }
1020
1021 fn insert(
1022 &mut self,
1023 asset: DigestedAsset,
1024 bytes: Arc<[u8]>,
1025 ) -> Result<(), SiteSnapshotBuildError> {
1026 if let Some(existing) = self.by_path.get(&asset.path) {
1027 if existing.asset == asset {
1028 return Ok(());
1029 }
1030 return Err(SiteSnapshotBuildError::new(
1031 SiteSnapshotBuildErrorCode::AssetCollision,
1032 None,
1033 "selected assets disagree at one logical path",
1034 ));
1035 }
1036
1037 let retained_bytes =
1038 next_public_asset_bytes(self.by_path.len(), self.retained_bytes, bytes.len())?;
1039 self.by_path
1040 .insert(asset.path.clone(), SelectedAsset { asset, bytes });
1041 self.retained_bytes = retained_bytes;
1042 Ok(())
1043 }
1044}
1045
1046fn next_public_asset_bytes(
1047 current_count: usize,
1048 current_bytes: usize,
1049 asset_bytes: usize,
1050) -> Result<usize, SiteSnapshotBuildError> {
1051 if current_count
1052 .checked_add(1)
1053 .is_none_or(|count| count > MAX_PUBLIC_ASSETS)
1054 {
1055 return Err(SiteSnapshotBuildError::public_asset_count_limit());
1056 }
1057 let Some(next_bytes) = current_bytes.checked_add(asset_bytes) else {
1058 return Err(SiteSnapshotBuildError::retained_asset_limit());
1059 };
1060 if next_bytes > MAX_RETAINED_ASSET_BYTES {
1061 return Err(SiteSnapshotBuildError::retained_asset_limit());
1062 }
1063 Ok(next_bytes)
1064}
1065
1066fn insert_authored_asset(
1067 selected: &mut SelectedAssets,
1068 asset: &DigestedAsset,
1069 store: &ResolvedLocalAssetStore,
1070) -> Result<(), SiteSnapshotBuildError> {
1071 let resolved = store.resolve(asset).map_err(|error| {
1072 SiteSnapshotBuildError::new(
1073 SiteSnapshotBuildErrorCode::AssetUnavailable,
1074 None,
1075 error.to_string(),
1076 )
1077 })?;
1078 selected.insert(asset.clone(), Arc::clone(&resolved.bytes))
1079}
1080
1081#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
1082enum PageRoute {
1083 Index,
1084 Post(PostSlug),
1085 Tag(PostTag),
1086 Archive,
1087}
1088
1089impl PageRoute {
1090 fn public_path(&self) -> PublicPagePath {
1091 match self {
1092 Self::Index => PublicPagePath::index(),
1093 Self::Post(slug) => PublicPagePath::post(slug),
1094 Self::Tag(tag) => PublicPagePath::tag(tag),
1095 Self::Archive => PublicPagePath::archive(),
1096 }
1097 }
1098}
1099
1100#[derive(Clone, Debug, Eq, PartialEq)]
1101struct RenderedPage {
1102 html: Arc<str>,
1103 canonical_url: CanonicalSiteUrl,
1104}
1105
1106#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1107pub(crate) struct PresentationDigest([u8; 32]);
1108
1109impl fmt::Display for PresentationDigest {
1110 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1111 write!(
1112 formatter,
1113 "presentation-b3-v1-{}",
1114 blake3::Hash::from_bytes(self.0).to_hex()
1115 )
1116 }
1117}
1118
1119fn presentation_digest(
1120 pages: &BTreeMap<PageRoute, RenderedPage>,
1121 redirects: &BTreeMap<PostAlias, Arc<CanonicalSiteUrl>>,
1122 not_found: &RenderedPage,
1123 method_not_allowed: &RenderedPage,
1124 feed: &RenderedRssFeed,
1125 robots: &RenderedRobots,
1126 sitemap: &RenderedSitemap,
1127) -> PresentationDigest {
1128 let mut hasher = blake3::Hasher::new_derive_key("maincopy presentation snapshot v1");
1129 hash_presentation_part(&mut hasher, &(pages.len() as u64).to_be_bytes());
1130 for (route, page) in pages {
1131 hash_presentation_part(&mut hasher, route.public_path().as_str().as_bytes());
1132 hash_presentation_part(&mut hasher, page.html.as_bytes());
1133 }
1134 hash_presentation_part(&mut hasher, &(redirects.len() as u64).to_be_bytes());
1135 for (alias, target) in redirects {
1136 hash_presentation_part(
1137 &mut hasher,
1138 PublicPagePath::post_alias(alias).as_str().as_bytes(),
1139 );
1140 hash_presentation_part(&mut hasher, target.as_str().as_bytes());
1141 }
1142 hash_presentation_part(&mut hasher, b"not-found");
1143 hash_presentation_part(&mut hasher, not_found.html.as_bytes());
1144 hash_presentation_part(&mut hasher, b"method-not-allowed");
1145 hash_presentation_part(&mut hasher, method_not_allowed.html.as_bytes());
1146 hash_presentation_part(&mut hasher, PublicPagePath::feed().as_str().as_bytes());
1147 hash_presentation_part(&mut hasher, feed.body.as_bytes());
1148 hash_presentation_part(&mut hasher, PublicPagePath::robots().as_str().as_bytes());
1149 hash_presentation_part(&mut hasher, robots.body.as_bytes());
1150 hash_presentation_part(&mut hasher, PublicPagePath::sitemap().as_str().as_bytes());
1151 hash_presentation_part(&mut hasher, sitemap.body.as_bytes());
1152 PresentationDigest(*hasher.finalize().as_bytes())
1153}
1154
1155fn hash_presentation_part(hasher: &mut blake3::Hasher, value: &[u8]) {
1156 hasher.update(&(value.len() as u64).to_be_bytes());
1157 hasher.update(value);
1158}
1159
1160#[derive(Clone, Debug, Eq, PartialEq)]
1161pub(crate) struct SnapshotPublicAsset {
1162 pub(crate) digest: AssetDigest,
1163 pub(crate) bytes: Arc<[u8]>,
1164 pub(crate) delivery: AssetDelivery,
1165}
1166
1167pub struct SiteSnapshot {
1169 pub(crate) digest: SiteSnapshotDigest,
1170 pub(crate) presentation_digest: PresentationDigest,
1171 pub(crate) feed: RenderedRssFeed,
1172 pub(crate) robots: RenderedRobots,
1173 pub(crate) sitemap: RenderedSitemap,
1174 pages: BTreeMap<PageRoute, RenderedPage>,
1175 redirects: BTreeMap<PostAlias, Arc<CanonicalSiteUrl>>,
1176 not_found: RenderedPage,
1177 method_not_allowed: RenderedPage,
1178 assets: BTreeMap<SnapshotAssetPath, SnapshotPublicAsset>,
1179 pub(crate) frontend: &'static FrontendAssetManifest,
1180 pub(crate) response_policy: PublicResponsePolicy,
1181 retained_html_bytes: usize,
1182}
1183
1184impl fmt::Debug for SiteSnapshot {
1185 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1186 formatter
1187 .debug_struct("SiteSnapshot")
1188 .field("digest", &self.digest)
1189 .field("presentation_digest", &self.presentation_digest)
1190 .field("feed_digest", &self.feed.digest)
1191 .field("robots_digest", &self.robots.digest)
1192 .field("sitemap_digest", &self.sitemap.digest)
1193 .field("pages", &self.pages.len())
1194 .field("redirects", &self.redirects.len())
1195 .field("assets", &self.assets.len())
1196 .field("retained_html_bytes", &self.retained_html_bytes)
1197 .finish_non_exhaustive()
1198 }
1199}
1200
1201impl SiteSnapshot {
1202 pub fn post_canonical_url(&self, slug: &PostSlug) -> Option<&CanonicalSiteUrl> {
1203 self.pages
1204 .get(&PageRoute::Post(slug.clone()))
1205 .map(|page| &page.canonical_url)
1206 }
1207
1208 pub(crate) fn public_asset(&self, path: &SnapshotAssetPath) -> Option<&SnapshotPublicAsset> {
1209 self.assets.get(path)
1210 }
1211
1212 pub(crate) fn alias_target(&self, alias: &PostAlias) -> Option<&CanonicalSiteUrl> {
1213 self.redirects.get(alias).map(Arc::as_ref)
1214 }
1215
1216 pub(crate) fn index_page(&self) -> Arc<str> {
1217 self.pages
1218 .get(&PageRoute::Index)
1219 .map_or_else(|| Arc::from(""), |page| Arc::clone(&page.html))
1220 }
1221
1222 pub(crate) fn post_page(&self, slug: &PostSlug) -> Option<Arc<str>> {
1223 self.pages
1224 .get(&PageRoute::Post(slug.clone()))
1225 .map(|page| Arc::clone(&page.html))
1226 }
1227
1228 pub(crate) fn tag_page(&self, tag: &PostTag) -> Option<Arc<str>> {
1229 self.pages
1230 .get(&PageRoute::Tag(tag.clone()))
1231 .map(|page| Arc::clone(&page.html))
1232 }
1233
1234 pub(crate) fn archive_page(&self) -> Arc<str> {
1235 self.pages
1236 .get(&PageRoute::Archive)
1237 .map_or_else(|| Arc::from(""), |page| Arc::clone(&page.html))
1238 }
1239
1240 pub(crate) fn not_found_page(&self) -> Arc<str> {
1241 Arc::clone(&self.not_found.html)
1242 }
1243
1244 pub(crate) fn method_not_allowed_page(&self) -> Arc<str> {
1245 Arc::clone(&self.method_not_allowed.html)
1246 }
1247}
1248
1249#[derive(Clone)]
1251pub struct SiteSnapshotReader {
1252 active: Arc<ArcSwap<SiteSnapshot>>,
1253}
1254
1255impl fmt::Debug for SiteSnapshotReader {
1256 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1257 formatter
1258 .debug_struct("SiteSnapshotReader")
1259 .finish_non_exhaustive()
1260 }
1261}
1262
1263impl SiteSnapshotReader {
1264 pub fn from_snapshot(snapshot: SiteSnapshot) -> Self {
1265 Self {
1266 active: Arc::new(ArcSwap::from_pointee(snapshot)),
1267 }
1268 }
1269
1270 pub fn load_full(&self) -> Arc<SiteSnapshot> {
1271 self.active.load_full()
1272 }
1273}
1274
1275pub(crate) struct SiteSnapshotActivator {
1276 active: Arc<ArcSwap<SiteSnapshot>>,
1277}
1278
1279pub(crate) fn snapshot_store(initial: SiteSnapshot) -> (SiteSnapshotReader, SiteSnapshotActivator) {
1280 let active = Arc::new(ArcSwap::from_pointee(initial));
1281 (
1282 SiteSnapshotReader {
1283 active: Arc::clone(&active),
1284 },
1285 SiteSnapshotActivator { active },
1286 )
1287}
1288
1289impl SiteSnapshotActivator {
1290 pub(crate) fn activate(
1291 &mut self,
1292 expected: &SiteSnapshotDigest,
1293 next: SiteSnapshot,
1294 ) -> Result<SnapshotActivationOutcome, SnapshotActivationError> {
1295 let current = self.active.load_full();
1296 if ¤t.digest != expected {
1297 return Err(SnapshotActivationError {
1298 expected: expected.clone(),
1299 actual: current.digest.clone(),
1300 });
1301 }
1302 if current.digest == next.digest && current.presentation_digest == next.presentation_digest
1303 {
1304 return Ok(SnapshotActivationOutcome::AlreadyActive);
1305 }
1306 self.active.store(Arc::new(next));
1307 Ok(SnapshotActivationOutcome::Activated)
1308 }
1309}
1310
1311#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1312#[serde(rename_all = "snake_case")]
1313pub(crate) enum SnapshotActivationOutcome {
1314 Activated,
1315 AlreadyActive,
1316}
1317
1318#[derive(Clone, Debug, Eq, Error, PartialEq)]
1319#[error("expected active snapshot {expected}, found {actual}")]
1320pub(crate) struct SnapshotActivationError {
1321 expected: SiteSnapshotDigest,
1322 actual: SiteSnapshotDigest,
1323}
1324
1325#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1326#[serde(rename_all = "snake_case")]
1327pub enum SiteSnapshotBuildErrorCode {
1328 FrontendManifestInvalid,
1329 ResponsePolicyInvalid,
1330 RevisionUnavailable,
1331 DraftSelected,
1332 RouteCollision,
1333 RouteLimitExceeded,
1334 PageLimitExceeded,
1335 RetainedHtmlLimitExceeded,
1336 AssetUnavailable,
1337 AssetCollision,
1338 PublicAssetCountLimitExceeded,
1339 RetainedAssetLimitExceeded,
1340 ArticleProjectionFailed,
1341 RssRenderFailed,
1342 RobotsRenderFailed,
1343 SitemapRenderFailed,
1344 MetadataRenderFailed,
1345 QrCodeGenerationFailed,
1346 IdentityRejected,
1347}
1348
1349#[derive(Clone, Debug, Eq, Error, PartialEq)]
1350#[error("{code:?}: {message}")]
1351pub struct SiteSnapshotBuildError {
1352 pub code: SiteSnapshotBuildErrorCode,
1353 pub post_id: Option<PostId>,
1354 pub message: Box<str>,
1355}
1356
1357impl SiteSnapshotBuildError {
1358 fn new(
1359 code: SiteSnapshotBuildErrorCode,
1360 post_id: Option<PostId>,
1361 message: impl Into<Box<str>>,
1362 ) -> Self {
1363 Self {
1364 code,
1365 post_id,
1366 message: message.into(),
1367 }
1368 }
1369
1370 fn post(
1371 code: SiteSnapshotBuildErrorCode,
1372 post_id: PostId,
1373 message: impl Into<Box<str>>,
1374 ) -> Self {
1375 Self::new(code, Some(post_id), message)
1376 }
1377
1378 fn frontend(message: impl Into<Box<str>>) -> Self {
1379 Self::new(
1380 SiteSnapshotBuildErrorCode::FrontendManifestInvalid,
1381 None,
1382 message,
1383 )
1384 }
1385
1386 fn identity(error: RevisionIdentityError) -> Self {
1387 Self::new(
1388 SiteSnapshotBuildErrorCode::IdentityRejected,
1389 None,
1390 error.to_string(),
1391 )
1392 }
1393
1394 fn qr_code(message: impl Into<Box<str>>) -> Self {
1395 Self::new(
1396 SiteSnapshotBuildErrorCode::QrCodeGenerationFailed,
1397 None,
1398 message,
1399 )
1400 }
1401
1402 fn rss(error: RssRenderError) -> Self {
1403 let post_id = match &error {
1404 RssRenderError::IllegalXmlCharacter { post_id, .. } => post_id.clone(),
1405 RssRenderError::PublishedAtNotRepresentable { post_id, .. } => Some(post_id.clone()),
1406 RssRenderError::OutputTooLarge { .. } | RssRenderError::InvalidUtf8(_) => None,
1407 };
1408 Self::new(
1409 SiteSnapshotBuildErrorCode::RssRenderFailed,
1410 post_id,
1411 error.to_string(),
1412 )
1413 }
1414
1415 fn sitemap(error: SitemapRenderError) -> Self {
1416 Self::new(
1417 SiteSnapshotBuildErrorCode::SitemapRenderFailed,
1418 None,
1419 error.to_string(),
1420 )
1421 }
1422
1423 fn robots(error: RobotsRenderError) -> Self {
1424 Self::new(
1425 SiteSnapshotBuildErrorCode::RobotsRenderFailed,
1426 None,
1427 error.to_string(),
1428 )
1429 }
1430
1431 fn metadata(post_id: &PostId, error: MetadataRenderError) -> Self {
1432 Self::post(
1433 SiteSnapshotBuildErrorCode::MetadataRenderFailed,
1434 post_id.clone(),
1435 error.to_string(),
1436 )
1437 }
1438
1439 fn route_limit() -> Self {
1440 Self::new(
1441 SiteSnapshotBuildErrorCode::RouteLimitExceeded,
1442 None,
1443 format!("site exceeds the inclusive {MAX_PUBLIC_ROUTES}-route limit"),
1444 )
1445 }
1446
1447 fn retained_html_limit() -> Self {
1448 Self::new(
1449 SiteSnapshotBuildErrorCode::RetainedHtmlLimitExceeded,
1450 None,
1451 format!(
1452 "site exceeds the inclusive {MAX_RETAINED_HTML_BYTES}-byte retained HTML limit"
1453 ),
1454 )
1455 }
1456
1457 fn public_asset_count_limit() -> Self {
1458 Self::new(
1459 SiteSnapshotBuildErrorCode::PublicAssetCountLimitExceeded,
1460 None,
1461 format!("site exceeds the inclusive {MAX_PUBLIC_ASSETS}-asset limit"),
1462 )
1463 }
1464
1465 fn retained_asset_limit() -> Self {
1466 Self::new(
1467 SiteSnapshotBuildErrorCode::RetainedAssetLimitExceeded,
1468 None,
1469 format!(
1470 "site exceeds the inclusive {MAX_RETAINED_ASSET_BYTES}-byte retained asset limit"
1471 ),
1472 )
1473 }
1474}
1475
1476#[derive(Clone, Copy)]
1478enum HeadAssetProjection<'projection> {
1479 Identity,
1480 Snapshot(&'projection SiteSnapshotDigest),
1481 Preview(&'projection str),
1482}
1483
1484struct PageRenderer<'render> {
1485 publication: &'render PublicationSettings,
1486 frontend: &'static FrontendAssetManifest,
1487 projection: HeadAssetProjection<'render>,
1488 favicon: Option<String>,
1489 image: Option<String>,
1490 policy: PublicResponsePolicy,
1491}
1492
1493impl<'render> PageRenderer<'render> {
1494 fn new(
1495 publication: &'render PublicationSettings,
1496 frontend: &'static FrontendAssetManifest,
1497 assets: &ResolvedSiteAssets,
1498 projection: HeadAssetProjection<'render>,
1499 ) -> Result<Self, SiteSnapshotBuildError> {
1500 let policy =
1501 PublicResponsePolicy::new(&assets.allowed_origins, frontend).map_err(|error| {
1502 SiteSnapshotBuildError::new(
1503 SiteSnapshotBuildErrorCode::ResponsePolicyInvalid,
1504 None,
1505 error.to_string(),
1506 )
1507 })?;
1508 let mut renderer = Self {
1509 publication,
1510 frontend,
1511 projection,
1512 favicon: None,
1513 image: None,
1514 policy,
1515 };
1516 renderer.favicon = assets
1517 .favicon
1518 .as_ref()
1519 .map(|asset| renderer.project_asset(asset))
1520 .transpose()?;
1521 renderer.image = renderer.project_metadata_image(assets.image.as_ref())?;
1522 Ok(renderer)
1523 }
1524
1525 fn project_metadata_image(
1526 &self,
1527 asset: Option<&AssetRevisionReference>,
1528 ) -> Result<Option<String>, SiteSnapshotBuildError> {
1529 match (self.projection, asset) {
1532 (HeadAssetProjection::Preview(_), Some(AssetRevisionReference::Local(_))) => Ok(None),
1533 (_, asset) => asset.map(|asset| self.project_asset(asset)).transpose(),
1534 }
1535 }
1536
1537 fn project_asset(
1538 &self,
1539 asset: &AssetRevisionReference,
1540 ) -> Result<String, SiteSnapshotBuildError> {
1541 let asset = match asset {
1542 AssetRevisionReference::Local(asset) => asset,
1543 AssetRevisionReference::External(url) => return Ok(url.as_str().to_owned()),
1544 };
1545 let path = match self.projection {
1546 HeadAssetProjection::Identity => format!(
1547 "/assets/maincopy-snapshot-placeholder/{}",
1548 asset.path.as_str()
1549 ),
1550 HeadAssetProjection::Snapshot(digest) => SnapshotAssetPath::new(digest, &asset.path)
1551 .map_err(|error| {
1552 SiteSnapshotBuildError::new(
1553 SiteSnapshotBuildErrorCode::ArticleProjectionFailed,
1554 None,
1555 error.to_string(),
1556 )
1557 })?
1558 .as_str()
1559 .to_owned(),
1560 HeadAssetProjection::Preview(endpoint) => {
1561 return Ok(format!("{endpoint}?path={}", asset.path.as_str()));
1562 }
1563 };
1564 Ok(format!(
1565 "{}{}",
1566 self.publication
1567 .site
1568 .base_url
1569 .as_str()
1570 .trim_end_matches('/'),
1571 path
1572 ))
1573 }
1574}
1575
1576fn render_index(
1577 renderer: &PageRenderer<'_>,
1578 posts: &[PublicPostView],
1579 chronology: &[usize],
1580) -> Markup {
1581 let publication = renderer.publication;
1582 let canonical_url =
1583 CanonicalSiteUrl::for_path(&publication.site.base_url, &PublicPagePath::index());
1584 let content = html! {
1585 section class="maincopy-index" aria-labelledby="recent-posts-heading" {
1586 h1 id="recent-posts-heading" { "Recent posts" }
1587 @if chronology.is_empty() {
1588 p { "No posts have been published yet." }
1589 } @else {
1590 (render_post_list(posts, chronology))
1591 }
1592 }
1593 };
1594 render_layout(
1595 renderer,
1596 PageHead {
1597 image: renderer.image.as_deref(),
1598 context: PageContext::Index,
1599 title: publication.site.title.as_str(),
1600 description: publication.site.description.as_str(),
1601 canonical: Some(CanonicalPageHead {
1602 url: &canonical_url,
1603 kind: CanonicalPageKind::Website,
1604 }),
1605 },
1606 content,
1607 )
1608}
1609
1610fn render_archive(
1611 renderer: &PageRenderer<'_>,
1612 posts: &[PublicPostView],
1613 chronology: &[usize],
1614) -> Markup {
1615 let publication = renderer.publication;
1616 let canonical_url =
1617 CanonicalSiteUrl::for_path(&publication.site.base_url, &PublicPagePath::archive());
1618 let description = format!(
1619 "Browse every published post from {}.",
1620 publication.site.title.as_str()
1621 );
1622 let content = html! {
1623 section class="maincopy-archive" aria-labelledby="archive-heading" {
1624 h1 id="archive-heading" { "Archive" }
1625 @if chronology.is_empty() {
1626 p { "No posts have been published yet." }
1627 } @else {
1628 (render_post_list(posts, chronology))
1629 }
1630 }
1631 };
1632 render_layout(
1633 renderer,
1634 PageHead {
1635 image: renderer.image.as_deref(),
1636 context: PageContext::Archive,
1637 title: "Archive",
1638 description: &description,
1639 canonical: Some(CanonicalPageHead {
1640 url: &canonical_url,
1641 kind: CanonicalPageKind::Website,
1642 }),
1643 },
1644 content,
1645 )
1646}
1647
1648fn render_tag(
1649 renderer: &PageRenderer<'_>,
1650 tag: &PostTag,
1651 posts: &[PublicPostView],
1652 indexes: &[usize],
1653) -> Markup {
1654 let publication = renderer.publication;
1655 let title = format!("Posts tagged {}", tag.as_str());
1656 let description = format!(
1657 "Browse published posts tagged “{}” on {}.",
1658 tag.as_str(),
1659 publication.site.title.as_str()
1660 );
1661 let canonical_url =
1662 CanonicalSiteUrl::for_path(&publication.site.base_url, &PublicPagePath::tag(tag));
1663 let content = html! {
1664 section class="maincopy-tag" aria-labelledby="tag-heading" {
1665 h1 id="tag-heading" { "Posts tagged “" (tag.as_str()) "”" }
1666 (render_post_list(posts, indexes))
1667 }
1668 };
1669 render_layout(
1670 renderer,
1671 PageHead {
1672 image: renderer.image.as_deref(),
1673 context: PageContext::Tag,
1674 title: &title,
1675 description: &description,
1676 canonical: Some(CanonicalPageHead {
1677 url: &canonical_url,
1678 kind: CanonicalPageKind::Website,
1679 }),
1680 },
1681 content,
1682 )
1683}
1684
1685#[derive(Clone, Copy)]
1686enum ArticleBody<'article> {
1687 Omitted,
1688 Projected(&'article ProjectedArticleHtml),
1689}
1690
1691#[derive(Clone, Copy)]
1692struct PostPageView<'post> {
1693 post_id: &'post PostId,
1694 title: &'post PostTitle,
1695 description: &'post PostDescription,
1696 tags: &'post [PostTag],
1697 authored_at: OffsetDateTime,
1698 updated_at: Option<OffsetDateTime>,
1699 published_at: Option<OffsetDateTime>,
1700 tips: PostTipPolicy,
1701 image: Option<&'post AssetRevisionReference>,
1702}
1703
1704impl<'post> PostPageView<'post> {
1705 fn from_public(post: &'post PublicPostView) -> Self {
1706 Self {
1707 post_id: &post.post_id,
1708 title: &post.title,
1709 description: &post.description,
1710 tags: &post.tags,
1711 authored_at: post.authored_at,
1712 updated_at: post.updated_at,
1713 published_at: Some(post.published_at),
1714 tips: post.tips,
1715 image: post.image.as_ref(),
1716 }
1717 }
1718
1719 fn from_rendered(rendered: &'post RenderedPost, published_at: Option<OffsetDateTime>) -> Self {
1720 let metadata = &rendered.document.metadata;
1721 Self {
1722 post_id: &metadata.id,
1723 title: &metadata.title,
1724 description: &metadata.description,
1725 tags: &metadata.tags,
1726 authored_at: metadata.authored_at,
1727 updated_at: metadata.updated_at,
1728 published_at,
1729 tips: metadata.tips,
1730 image: rendered.assets.image.as_ref(),
1731 }
1732 }
1733
1734 fn tips_enabled(self, publication: &PublicationSettings) -> bool {
1735 match self.tips {
1736 PostTipPolicy::Enabled => true,
1737 PostTipPolicy::Disabled => false,
1738 PostTipPolicy::InheritPublication => match publication.tips {
1739 DefaultPostTipPolicy::Enabled => true,
1740 DefaultPostTipPolicy::Disabled => false,
1741 },
1742 }
1743 }
1744}
1745
1746#[derive(Clone, Copy, Default)]
1747struct PostNavigation<'post> {
1748 previous: Option<&'post PublicPostView>,
1749 next: Option<&'post PublicPostView>,
1750}
1751
1752impl<'post> PostNavigation<'post> {
1753 fn from_indexes(posts: &'post [PublicPostView], indexes: ChronologicalNeighbors) -> Self {
1754 Self {
1755 previous: indexes.previous.map(|index| &posts[index]),
1756 next: indexes.next.map(|index| &posts[index]),
1757 }
1758 }
1759
1760 const fn is_empty(self) -> bool {
1761 self.previous.is_none() && self.next.is_none()
1762 }
1763}
1764
1765struct TipHandoff<'recipient> {
1766 recipient: &'recipient TipRecipientProjection,
1767 qr: Markup,
1768}
1769
1770impl<'recipient> TipHandoff<'recipient> {
1771 fn new(recipient: &'recipient TipRecipientProjection) -> Result<Self, SiteSnapshotBuildError> {
1772 let view = recipient.as_view();
1773 let code = QrCode::new(view.lnurl.as_bytes())
1774 .map_err(|error| SiteSnapshotBuildError::qr_code(error.to_string()))?;
1775 Ok(Self {
1776 recipient,
1777 qr: render_tip_qr(&code, view.address, view.lnurl),
1778 })
1779 }
1780}
1781
1782fn render_tip_qr(code: &QrCode, address: &str, lnurl: &str) -> Markup {
1783 const QUIET_ZONE_MODULES: usize = 4;
1784
1785 let dimension = code.width() + 2 * QUIET_ZONE_MODULES;
1786 let mut path = String::new();
1787 for y in 0..code.width() {
1788 for x in 0..code.width() {
1789 if code[(x, y)] == Color::Dark {
1790 let _ = write!(
1791 path,
1792 "M{} {}h1v1h-1z",
1793 x + QUIET_ZONE_MODULES,
1794 y + QUIET_ZONE_MODULES
1795 );
1796 }
1797 }
1798 }
1799 let label = format!("QR code for tipping {address} with Lightning");
1800 html! {
1801 svg class="tip-qr" xmlns="http://www.w3.org/2000/svg"
1802 viewBox=(format!("0 0 {dimension} {dimension}")) role="img"
1803 aria-label=(label) data-lnurl=(lnurl) {
1804 rect width="100%" height="100%" fill="white" {}
1805 path d=(path) fill="black" {}
1806 }
1807 }
1808}
1809
1810fn render_tip_cta(handoff: &TipHandoff<'_>) -> Markup {
1811 let view = handoff.recipient.as_view();
1812 let recipient = view.display_name.unwrap_or(view.address);
1813 html! {
1814 aside class="tip-cta" aria-labelledby="tip-heading" {
1815 h2 id="tip-heading" { "Enjoyed this article?" }
1816 p { "Send a tip to " (recipient) "." }
1817 p {
1818 a class="tip-action" href=(view.wallet_link) { "Tip with Lightning" }
1819 }
1820 p class="tip-recipient" {
1821 "Lightning Address: " code { (view.address) }
1822 " "
1823 button type="button" class="tip-copy" hidden
1824 data-copy-lightning-address=(view.address) { "Copy" }
1825 }
1826 (handoff.qr.clone())
1827 p { "Your wallet will ask for the amount and apply the recipient service's limits." }
1828 p { "Tips are voluntary and are handled by your wallet and the recipient's Lightning service." }
1829 }
1830 }
1831}
1832
1833fn render_post_navigation(navigation: PostNavigation<'_>) -> Markup {
1834 html! {
1835 @if !navigation.is_empty() {
1836 nav class="maincopy-post-navigation" aria-label="Post navigation" {
1837 @if let Some(previous) = navigation.previous {
1838 a class="maincopy-post-navigation-link maincopy-post-navigation-previous"
1839 href=(previous.public_path().as_str()) rel="prev" {
1840 span class="maincopy-post-navigation-label" { "Previous post" }
1841 span class="maincopy-post-navigation-title" { (previous.title.as_str()) }
1842 }
1843 }
1844 @if let Some(next) = navigation.next {
1845 a class="maincopy-post-navigation-link maincopy-post-navigation-next"
1846 href=(next.public_path().as_str()) rel="next" {
1847 span class="maincopy-post-navigation-label" { "Next post" }
1848 span class="maincopy-post-navigation-title" { (next.title.as_str()) }
1849 }
1850 }
1851 }
1852 }
1853 }
1854}
1855
1856fn render_post(
1857 renderer: &PageRenderer<'_>,
1858 post: PostPageView<'_>,
1859 canonical_url: &CanonicalSiteUrl,
1860 article: ArticleBody<'_>,
1861 navigation: PostNavigation<'_>,
1862 tip_handoff: Option<&TipHandoff<'_>>,
1863) -> Result<Markup, SiteSnapshotBuildError> {
1864 let publication = renderer.publication;
1865 let image = renderer.project_metadata_image(post.image)?;
1866 let metadata = render_post_head_metadata(PostHeadMetadataInput {
1867 title: post.title,
1868 description: post.description,
1869 tags: post.tags,
1870 authored_at: post.authored_at,
1871 updated_at: post.updated_at,
1872 published_at: post.published_at,
1873 canonical_url,
1874 author: &publication.author.name,
1875 image: image.as_deref(),
1876 })
1877 .map_err(|error| SiteSnapshotBuildError::metadata(post.post_id, error))?;
1878 let tips_enabled = post.tips_enabled(publication);
1879 let content = html! {
1880 div class="maincopy-post-page" {
1881 article class="maincopy-post" {
1882 header class="maincopy-post-header" {
1883 h1 { (post.title.as_str()) }
1884 p { (post.description.as_str()) }
1885 @if let Some(published_at) = post.published_at {
1886 p class="publication-time" {
1887 "Published "
1888 time datetime=(published_at.to_string()) {
1889 (published_at.to_string())
1890 }
1891 }
1892 }
1893 p class="author-time" {
1894 "Authored "
1895 time datetime=(post.authored_at.to_string()) {
1896 (post.authored_at.to_string())
1897 }
1898 @if let Some(updated_at) = post.updated_at {
1899 " · Updated "
1900 time datetime=(updated_at.to_string()) { (updated_at.to_string()) }
1901 }
1902 }
1903 @if !post.tags.is_empty() {
1904 ul class="maincopy-post-tags" aria-label="Tags" {
1905 @for tag in post.tags {
1906 li {
1907 a href=(format!("/tags/{}", tag.as_str())) { (tag.as_str()) }
1908 }
1909 }
1910 }
1911 }
1912 }
1913 section class="maincopy-post-content" {
1914 @if let ArticleBody::Projected(article) = article {
1915 (trusted_article_markup(article))
1916 }
1917 }
1918 @if tips_enabled {
1919 @if let Some(tip_handoff) = tip_handoff {
1920 (render_tip_cta(tip_handoff))
1921 }
1922 }
1923 }
1924 (render_post_navigation(navigation))
1925 }
1926 };
1927 Ok(render_layout(
1928 renderer,
1929 PageHead {
1930 image: image.as_deref(),
1931 context: PageContext::Post,
1932 title: post.title.as_str(),
1933 description: post.description.as_str(),
1934 canonical: Some(CanonicalPageHead {
1935 url: canonical_url,
1936 kind: CanonicalPageKind::Article {
1937 metadata: &metadata,
1938 tags: post.tags,
1939 },
1940 }),
1941 },
1942 content,
1943 ))
1944}
1945
1946fn render_post_list(posts: &[PublicPostView], indexes: &[usize]) -> Markup {
1947 html! {
1948 ol class="maincopy-post-list" {
1949 @for index in indexes {
1950 @let post = &posts[*index];
1951 li {
1952 article {
1953 h2 {
1954 a href=(post.public_path().as_str()) { (post.title.as_str()) }
1955 }
1956 p { (post.description.as_str()) }
1957 time datetime=(post.published_at.to_string()) {
1958 (post.published_at.to_string())
1959 }
1960 }
1961 }
1962 }
1963 }
1964 }
1965}
1966
1967#[derive(Clone, Copy)]
1968enum PublicErrorPage {
1969 NotFound,
1970 MethodNotAllowed,
1971}
1972
1973fn render_error(renderer: &PageRenderer<'_>, error: PublicErrorPage) -> Markup {
1974 let (title, explanation) = match error {
1975 PublicErrorPage::NotFound => ("Page not found", "The requested page does not exist."),
1976 PublicErrorPage::MethodNotAllowed => (
1977 "Method not allowed",
1978 "The requested method is not available for this page.",
1979 ),
1980 };
1981 render_layout(
1982 renderer,
1983 PageHead {
1984 image: None,
1985 context: PageContext::Error,
1986 title,
1987 description: explanation,
1988 canonical: None,
1989 },
1990 html! {
1991 section class="maincopy-error-page" {
1992 h1 { (title) }
1993 p { (explanation) }
1994 p { a href="/" { "Return to the publication index" } }
1995 }
1996 },
1997 )
1998}
1999
2000#[derive(Clone, Copy)]
2001struct PageHead<'head> {
2002 context: PageContext,
2003 title: &'head str,
2004 description: &'head str,
2005 canonical: Option<CanonicalPageHead<'head>>,
2006 image: Option<&'head str>,
2007}
2008
2009#[derive(Clone, Copy)]
2010enum PageContext {
2011 Index,
2012 Archive,
2013 Tag,
2014 Post,
2015 Error,
2016}
2017
2018impl PageContext {
2019 const fn body_class(self) -> &'static str {
2020 match self {
2021 Self::Index => "maincopy-site maincopy-page-index",
2022 Self::Archive => "maincopy-site maincopy-page-archive",
2023 Self::Tag => "maincopy-site maincopy-page-tag",
2024 Self::Post => "maincopy-site maincopy-page-post",
2025 Self::Error => "maincopy-site maincopy-page-error",
2026 }
2027 }
2028}
2029
2030#[derive(Clone, Copy)]
2031struct CanonicalPageHead<'head> {
2032 url: &'head CanonicalSiteUrl,
2033 kind: CanonicalPageKind<'head>,
2034}
2035
2036#[derive(Clone, Copy)]
2037enum CanonicalPageKind<'head> {
2038 Website,
2039 Article {
2040 metadata: &'head RenderedPostHeadMetadata,
2041 tags: &'head [PostTag],
2042 },
2043}
2044
2045fn render_layout(renderer: &PageRenderer<'_>, head: PageHead<'_>, content: Markup) -> Markup {
2046 let publication = renderer.publication;
2047 let frontend = renderer.frontend;
2048 let site = &publication.site;
2049 let feed_url = CanonicalSiteUrl::for_path(&site.base_url, &PublicPagePath::feed());
2050 let feed_title = format!("{} RSS feed", site.title.as_str());
2051 let full_title = if head.title == site.title.as_str() {
2052 head.title.to_owned()
2053 } else {
2054 format!("{} — {}", head.title, site.title.as_str())
2055 };
2056 html! {
2057 (DOCTYPE)
2058 html lang="en" prefix="og: https://ogp.me/ns# article: https://ogp.me/ns/article#" {
2059 head {
2060 meta charset="utf-8";
2061 meta name="viewport" content="width=device-width, initial-scale=1";
2062 meta name="description" content=(head.description);
2063 title { (full_title) }
2064 @if let Some(favicon) = &renderer.favicon {
2065 link rel="icon" href=(favicon);
2066 }
2067 @if let Some(canonical) = head.canonical {
2068 link rel="canonical" href=(canonical.url.as_str());
2069 meta property="og:title" content=(head.title);
2070 meta property="og:type" content=(match canonical.kind {
2071 CanonicalPageKind::Website => "website",
2072 CanonicalPageKind::Article { .. } => "article",
2073 });
2074 meta property="og:url" content=(canonical.url.as_str());
2075 meta property="og:description" content=(head.description);
2076 meta property="og:site_name" content=(site.title.as_str());
2077 @if let Some(image) = head.image {
2078 meta property="og:image" content=(image);
2079 }
2080 @if let CanonicalPageKind::Article { metadata, tags } = canonical.kind {
2081 @if let Some(published_time) = &metadata.published_time {
2082 meta property="article:published_time" content=(published_time);
2083 }
2084 @if let Some(modified_time) = &metadata.modified_time {
2085 meta property="article:modified_time" content=(modified_time);
2086 }
2087 @for tag in tags {
2088 meta property="article:tag" content=(tag.as_str());
2089 }
2090 (metadata.json_ld_script())
2091 }
2092 }
2093 link rel="alternate" type="application/rss+xml"
2094 title=(feed_title) href=(feed_url.as_str());
2095 link rel="stylesheet" href=(frontend.css.public_path);
2096 @if let Some(javascript) = &frontend.javascript {
2097 script src=(javascript.public_path) integrity=[renderer.policy.script_integrity.as_deref()] defer {}
2098 }
2099 }
2100 body class=(head.context.body_class()) {
2101 header class="maincopy-site-header" {
2102 a class="maincopy-site-title" href="/" { (site.title.as_str()) }
2103 nav class="maincopy-site-navigation" aria-label="Primary navigation" {
2104 ul {
2105 li { a href="/" { "Home" } }
2106 li { a href="/archive" { "Archive" } }
2107 }
2108 }
2109 }
2110 main class="maincopy-site-main" { (content) }
2111 footer class="maincopy-site-footer" {
2112 p { "Written by " (publication.author.name.as_str()) }
2113 }
2114 }
2115 }
2116 }
2117}
2118
2119struct ProjectedArticleHtml(Box<str>);
2120
2121impl ProjectedArticleHtml {
2122 fn new(value: String) -> Self {
2123 Self(value.into_boxed_str())
2124 }
2125}
2126
2127fn trusted_article_markup(article: &ProjectedArticleHtml) -> Markup {
2129 PreEscaped(article.0.to_string())
2130}
2131
2132#[cfg(test)]
2133mod tests {
2134 use std::{collections::BTreeMap, sync::Barrier, thread};
2135
2136 use maincopy_shared::profile::{LightningAddress, ProfileDisplayName};
2137
2138 use super::*;
2139 use crate::{frontend_assets::embedded_manifest, render::compile_content_catalog};
2140 use markdown_compiler::{
2141 LogicalAssetPath, PostCollection, ResolvedPostAssets, ResolvedSiteAssets, digest_asset,
2142 prepare_content,
2143 };
2144
2145 use crate::content_fixtures::{asset, content_tree, post, publication};
2146
2147 const FIRST_ID: &str = "11111111-1111-4111-8111-111111111111";
2148 const SECOND_ID: &str = "22222222-2222-4222-8222-222222222222";
2149 const DRAFT_ID: &str = "33333333-3333-4333-8333-333333333333";
2150
2151 struct Fixture {
2152 catalog: Arc<ContentCatalog>,
2153 revisions: BTreeMap<PostId, PostRevisionDigest>,
2154 }
2155
2156 struct PostRoutes<'route> {
2157 slug: &'route str,
2158 aliases: &'route [&'route str],
2159 }
2160
2161 fn post_source(
2162 id: &str,
2163 title: &str,
2164 routes: PostRoutes<'_>,
2165 tags: &[&str],
2166 image: Option<&str>,
2167 body: &str,
2168 draft: bool,
2169 ) -> String {
2170 let tags = tags
2171 .iter()
2172 .map(|tag| format!("{tag:?}"))
2173 .collect::<Vec<_>>()
2174 .join(", ");
2175 let aliases = routes
2176 .aliases
2177 .iter()
2178 .map(|alias| format!("{alias:?}"))
2179 .collect::<Vec<_>>()
2180 .join(", ");
2181 let image = image.map_or_else(String::new, |path| format!("image = {path:?}\n"));
2182 let slug = routes.slug;
2183 format!(
2184 "+++\n\
2185 id = {id:?}\n\
2186 title = {title:?}\n\
2187 slug = {slug:?}\n\
2188 authored_at = 2026-08-29T15:00:00-04:00\n\
2189 updated_at = 2026-08-29T16:00:00-04:00\n\
2190 description = \"Description <unsafe> & text.\"\n\
2191 {image}\
2192 tags = [{tags}]\n\
2193 aliases = [{aliases}]\n\
2194 draft = {draft}\n\
2195 +++\n\
2196 {body}"
2197 )
2198 }
2199
2200 fn fixture() -> Fixture {
2201 fixture_with_first(
2202 "# First\n<script>alert('body')</script>\n\n",
2203 b"public",
2204 )
2205 }
2206
2207 fn fixture_with_first(first_body: &str, public_asset: &[u8]) -> Fixture {
2208 fixture_with_routes(
2209 first_body,
2210 public_asset,
2211 "first-post",
2212 &["original-first-post"],
2213 "second-post",
2214 &["original-second-post"],
2215 )
2216 }
2217
2218 fn fixture_with_routes(
2219 first_body: &str,
2220 public_asset: &[u8],
2221 first_slug: &str,
2222 first_aliases: &[&str],
2223 second_slug: &str,
2224 second_aliases: &[&str],
2225 ) -> Fixture {
2226 let publication_source = "[site]\n\
2227 title = \"Site <unsafe> & title\"\n\
2228 base_url = \"https://blog.example.com/\"\n\
2229 description = \"A <careful> site.\"\n\
2230 favicon = \"assets/favicon.png\"\n\
2231 [author]\n\
2232 name = \"Author <unsafe>\"\n";
2233 let tree = content_tree(
2234 publication("publication.toml", publication_source.to_owned()),
2235 vec![
2236 post(
2237 "posts/first.md",
2238 PostCollection::Posts,
2239 post_source(
2240 FIRST_ID,
2241 "First <script>alert(1)</script>",
2242 PostRoutes {
2243 slug: first_slug,
2244 aliases: first_aliases,
2245 },
2246 &["rust"],
2247 Some("assets/first-cover.png"),
2248 first_body,
2249 false,
2250 ),
2251 ),
2252 post(
2253 "posts/second.md",
2254 PostCollection::Posts,
2255 post_source(
2256 SECOND_ID,
2257 "Second post",
2258 PostRoutes {
2259 slug: second_slug,
2260 aliases: second_aliases,
2261 },
2262 &["rust", "sqlite"],
2263 Some("assets/second-cover.png"),
2264 "# Second\n\n",
2265 false,
2266 ),
2267 ),
2268 post(
2269 "drafts/draft.md",
2270 PostCollection::Drafts,
2271 post_source(
2272 DRAFT_ID,
2273 "Draft post",
2274 PostRoutes {
2275 slug: "draft-post",
2276 aliases: &["draft-post-alias"],
2277 },
2278 &["draft-tag"],
2279 Some("assets/draft-cover.png"),
2280 "# Draft\n\n",
2281 true,
2282 ),
2283 ),
2284 ],
2285 vec![
2286 asset(
2287 LogicalAssetPath::parse("assets/favicon.png").unwrap(),
2288 b"favicon".to_vec(),
2289 ),
2290 asset(
2291 LogicalAssetPath::parse("assets/public.png").unwrap(),
2292 public_asset.to_vec(),
2293 ),
2294 asset(
2295 LogicalAssetPath::parse("assets/first-cover.png").unwrap(),
2296 b"first cover".to_vec(),
2297 ),
2298 asset(
2299 LogicalAssetPath::parse("assets/private.png").unwrap(),
2300 b"private".to_vec(),
2301 ),
2302 asset(
2303 LogicalAssetPath::parse("assets/second-cover.png").unwrap(),
2304 b"second cover".to_vec(),
2305 ),
2306 asset(
2307 LogicalAssetPath::parse("assets/draft.png").unwrap(),
2308 b"draft".to_vec(),
2309 ),
2310 asset(
2311 LogicalAssetPath::parse("assets/draft-cover.png").unwrap(),
2312 b"draft cover".to_vec(),
2313 ),
2314 ],
2315 0,
2316 );
2317 let content = prepare_content(&tree).unwrap();
2318 let catalog = Arc::new(compile_content_catalog(&content).unwrap());
2319 let revisions = catalog
2320 .rendered_posts()
2321 .map(|rendered| {
2322 (
2323 rendered.document.metadata.id.clone(),
2324 rendered.revision.clone(),
2325 )
2326 })
2327 .collect();
2328 Fixture { catalog, revisions }
2329 }
2330
2331 fn at(seconds: i64) -> OffsetDateTime {
2332 OffsetDateTime::from_unix_timestamp(seconds).unwrap()
2333 }
2334
2335 fn entry(fixture: &Fixture, id: &str, published_at: i64) -> PublishedPostRevision {
2336 let post_id = PostId::parse(id).unwrap();
2337 PublishedPostRevision::new(
2338 post_id.clone(),
2339 fixture.revisions[&post_id].clone(),
2340 at(published_at),
2341 )
2342 }
2343
2344 fn projection(
2345 entries: impl IntoIterator<Item = PublishedPostRevision>,
2346 ) -> PublicLedgerProjection {
2347 PublicLedgerProjection::try_from_exact_entries(entries).unwrap()
2348 }
2349
2350 fn build_snapshot(
2351 fixture: &Fixture,
2352 ledger: &PublicLedgerProjection,
2353 ) -> Result<SiteSnapshot, SiteSnapshotBuildError> {
2354 let shell = render_site_shell(Arc::clone(&fixture.catalog), embedded_manifest(), ledger)?;
2355 shell.into_snapshot()
2356 }
2357
2358 fn preview_asset_endpoint() -> String {
2359 format!(
2360 "/api/admin/v1/preview-assets/content-b3-v1-{}",
2361 "88".repeat(32)
2362 )
2363 }
2364
2365 fn tip_projection(display_name: Option<&str>, address: &str) -> TipRecipientProjection {
2366 TipRecipientProjection::from_validated_profile(
2367 display_name.map(|value| ProfileDisplayName::parse(value).unwrap()),
2368 LightningAddress::parse(address).unwrap(),
2369 )
2370 .unwrap()
2371 }
2372
2373 fn catalog_asset(fixture: &Fixture, path: &str) -> DigestedAsset {
2374 let path = LogicalAssetPath::parse(path).unwrap();
2375 fixture
2376 .catalog
2377 .site_assets
2378 .favicon
2379 .iter()
2380 .chain(fixture.catalog.site_assets.references.iter())
2381 .chain(fixture.catalog.rendered_posts().flat_map(|post| {
2382 post.assets
2383 .image
2384 .iter()
2385 .chain(post.assets.references.iter())
2386 }))
2387 .find_map(|reference| match reference {
2388 AssetRevisionReference::Local(asset) if asset.path == path => Some(asset.clone()),
2389 AssetRevisionReference::Local(_) | AssetRevisionReference::External(_) => None,
2390 })
2391 .expect("fixture asset reference must be present")
2392 }
2393
2394 fn assert_core_page_metadata(
2395 page: &str,
2396 title: &str,
2397 description: &str,
2398 canonical_url: &str,
2399 object_type: &str,
2400 ) {
2401 assert_eq!(page.matches("<link rel=\"canonical\"").count(), 1);
2402 assert!(page.contains(&format!(
2403 "<link rel=\"canonical\" href=\"{canonical_url}\">"
2404 )));
2405 for property in [
2406 "og:title",
2407 "og:type",
2408 "og:url",
2409 "og:description",
2410 "og:site_name",
2411 ] {
2412 assert_eq!(
2413 page.matches(&format!("<meta property=\"{property}\""))
2414 .count(),
2415 1,
2416 "unexpected {property} count in {page}"
2417 );
2418 }
2419 assert!(page.contains(&format!("<meta property=\"og:title\" content=\"{title}\">")));
2420 assert!(page.contains(&format!(
2421 "<meta property=\"og:type\" content=\"{object_type}\">"
2422 )));
2423 assert!(page.contains(&format!(
2424 "<meta property=\"og:url\" content=\"{canonical_url}\">"
2425 )));
2426 assert!(page.contains(&format!(
2427 "<meta property=\"og:description\" content=\"{description}\">"
2428 )));
2429 assert!(page.contains(
2430 "<meta property=\"og:site_name\" content=\"Site <unsafe> & title\">"
2431 ));
2432 }
2433
2434 fn post_json_ld(page: &str) -> serde_json::Value {
2435 const OPEN: &str = "<script type=\"application/ld+json\">";
2436 assert_eq!(page.matches(OPEN).count(), 1);
2437 let json = page
2438 .split_once(OPEN)
2439 .unwrap()
2440 .1
2441 .split_once("</script>")
2442 .unwrap()
2443 .0;
2444 assert!(!json.contains(['<', '>', '&']));
2445 serde_json::from_str(json).unwrap()
2446 }
2447
2448 fn rendered_head(page: &str) -> &str {
2449 page.split_once("<head>")
2450 .unwrap()
2451 .1
2452 .split_once("</head>")
2453 .unwrap()
2454 .0
2455 }
2456
2457 #[test]
2458 fn projection_rejects_duplicate_post_ids() {
2459 let fixture = fixture();
2460 let first = entry(&fixture, FIRST_ID, 1_000);
2461 let error =
2462 PublicLedgerProjection::try_from_exact_entries([first.clone(), first]).unwrap_err();
2463 assert_eq!(error.post_id().as_str(), FIRST_ID);
2464 assert!(PublicLedgerProjection::empty().is_empty());
2465 }
2466
2467 #[test]
2468 fn published_entry_is_inserted_in_exact_post_id_order() {
2469 let fixture = fixture();
2470 let original = projection([
2471 entry(&fixture, DRAFT_ID, 3_000),
2472 entry(&fixture, FIRST_ID, 1_000),
2473 ]);
2474
2475 let published = original
2476 .with_published(entry(&fixture, SECOND_ID, 2_000))
2477 .unwrap();
2478
2479 let post_ids: Vec<_> = published
2480 .published_posts()
2481 .map(|entry| entry.post_id.as_str())
2482 .collect();
2483 assert_eq!(post_ids, [FIRST_ID, SECOND_ID, DRAFT_ID]);
2484 assert_eq!(
2485 original
2486 .published_posts()
2487 .map(|entry| entry.post_id.as_str())
2488 .collect::<Vec<_>>(),
2489 [FIRST_ID, DRAFT_ID]
2490 );
2491 }
2492
2493 #[test]
2494 fn publishing_rejects_an_existing_post_id() {
2495 let fixture = fixture();
2496 let ledger = projection([entry(&fixture, FIRST_ID, 1_000)]);
2497
2498 let error = ledger
2499 .with_published(entry(&fixture, FIRST_ID, 2_000))
2500 .unwrap_err();
2501
2502 assert_eq!(error.post_id().as_str(), FIRST_ID);
2503 }
2504
2505 #[test]
2506 fn candidate_preview_uses_the_production_shell_for_every_publication_state() {
2507 let fixture = fixture();
2508 let asset_endpoint = preview_asset_endpoint();
2509
2510 let draft = render_post_preview(
2511 &fixture.catalog,
2512 embedded_manifest(),
2513 &PostId::parse(DRAFT_ID).unwrap(),
2514 &asset_endpoint,
2515 None,
2516 )
2517 .unwrap()
2518 .unwrap();
2519 assert!(draft.starts_with("<!DOCTYPE html>"));
2520 assert!(draft.contains("maincopy-site-header"));
2521 assert!(draft.contains("<h1>Draft post</h1>"));
2522 assert!(draft.contains("<h1>Draft</h1>"));
2523 assert!(draft.contains(&format!("{asset_endpoint}?path=assets/draft.png")));
2524 assert!(!draft.contains("class=\"publication-time\""));
2525 assert_core_page_metadata(
2526 &draft,
2527 "Draft post",
2528 "Description <unsafe> & text.",
2529 "https://blog.example.com/posts/draft-post",
2530 "article",
2531 );
2532 assert!(!draft.contains("property=\"article:published_time\""));
2533 assert!(post_json_ld(&draft).get("datePublished").is_none());
2534
2535 let unpublished = render_post_preview(
2536 &fixture.catalog,
2537 embedded_manifest(),
2538 &PostId::parse(SECOND_ID).unwrap(),
2539 &asset_endpoint,
2540 None,
2541 )
2542 .unwrap()
2543 .unwrap();
2544 assert!(unpublished.contains("<h1>Second post</h1>"));
2545 assert!(unpublished.contains("<h1>Second</h1>"));
2546 assert!(!unpublished.contains("class=\"publication-time\""));
2547
2548 let published = render_post_preview(
2549 &fixture.catalog,
2550 embedded_manifest(),
2551 &PostId::parse(FIRST_ID).unwrap(),
2552 &asset_endpoint,
2553 Some(at(2_000)),
2554 )
2555 .unwrap()
2556 .unwrap();
2557 assert!(published.contains("class=\"publication-time\""));
2558 assert!(published.contains("1970-01-01 0:33:20.0 +00:00:00"));
2559 assert!(published.contains(
2560 "<meta property=\"article:published_time\" content=\"1970-01-01T00:33:20Z\">"
2561 ));
2562 assert_eq!(
2563 post_json_ld(&published)["datePublished"],
2564 "1970-01-01T00:33:20Z"
2565 );
2566 let public =
2567 build_snapshot(&fixture, &projection([entry(&fixture, FIRST_ID, 2_000)])).unwrap();
2568 let public_post = public
2569 .post_page(&PostSlug::parse("first-post").unwrap())
2570 .unwrap();
2571 let public_prefix = format!("https://blog.example.com/assets/{}", public.digest);
2572 let image_url = format!("{public_prefix}/first-cover.png");
2573 assert_eq!(post_json_ld(&public_post)["image"], image_url);
2574 assert!(post_json_ld(&published).get("image").is_none());
2575 let public_head = rendered_head(&public_post)
2577 .replace(
2578 &format!("{public_prefix}/favicon.png"),
2579 &format!("{asset_endpoint}?path=assets/favicon.png"),
2580 )
2581 .replace(
2582 &format!("<meta property=\"og:image\" content=\"{image_url}\">"),
2583 "",
2584 )
2585 .replace(&format!(",\"image\":\"{image_url}\""), "");
2586 assert_eq!(rendered_head(&published), public_head);
2587
2588 assert!(
2589 render_post_preview(
2590 &fixture.catalog,
2591 embedded_manifest(),
2592 &PostId::parse("44444444-4444-4444-8444-444444444444").unwrap(),
2593 &asset_endpoint,
2594 None,
2595 )
2596 .unwrap()
2597 .is_none()
2598 );
2599 }
2600
2601 #[test]
2602 fn preview_binding_excludes_activation_and_private_asset_transport_metadata() {
2603 let fixture = fixture();
2604 let post_id = PostId::parse(FIRST_ID).unwrap();
2605 let first = render_bound_post_preview(
2606 &fixture.catalog,
2607 embedded_manifest(),
2608 &post_id,
2609 None,
2610 "/api/admin/v1/preview-assets/first-candidate",
2611 None,
2612 )
2613 .unwrap()
2614 .unwrap();
2615 let second = render_bound_post_preview(
2616 &fixture.catalog,
2617 embedded_manifest(),
2618 &post_id,
2619 None,
2620 "/api/admin/v1/preview-assets/second-candidate",
2621 Some(at(9_000)),
2622 )
2623 .unwrap()
2624 .unwrap();
2625
2626 assert_ne!(first.html, second.html);
2627 assert_eq!(first.digest, second.digest);
2628 assert_eq!(first.revision, fixture.revisions[&post_id]);
2629 assert_eq!(
2630 first.canonical_url.as_str(),
2631 "https://blog.example.com/posts/first-post"
2632 );
2633 }
2634
2635 #[test]
2636 fn tip_handoff_renders_exact_accessible_copy_and_escapes_the_display_name() {
2637 let projection = tip_projection(Some("Alice <Writer> & Company"), "alice@example.com");
2638 let handoff = TipHandoff::new(&projection).unwrap();
2639 let html = render_tip_cta(&handoff).into_string();
2640
2641 assert!(html.contains("<h2 id=\"tip-heading\">Enjoyed this article?</h2>"));
2642 assert!(html.contains("Send a tip to Alice <Writer> & Company."));
2643 assert!(html.contains(">Tip with Lightning</a>"));
2644 assert!(html.contains("Lightning Address: <code>alice@example.com</code>"));
2645 assert!(html.contains(
2646 "<button type=\"button\" class=\"tip-copy\" hidden data-copy-lightning-address=\"alice@example.com\">Copy</button>"
2647 ));
2648 assert!(html.contains(
2649 "Your wallet will ask for the amount and apply the recipient service's limits."
2650 ));
2651 assert!(html.contains("Tips are voluntary"));
2652 assert!(html.contains("role=\"img\""));
2653 assert!(
2654 html.contains("aria-label=\"QR code for tipping alice@example.com with Lightning\"")
2655 );
2656 assert!(!html.contains("<form"));
2657 assert!(!html.contains("<input"));
2658 assert!(!html.contains("invoice"));
2659 assert!(!html.contains("payment status"));
2660 assert!(!html.contains("success"));
2661 }
2662
2663 #[test]
2664 fn tip_handoff_falls_back_to_the_address_and_qr_matches_the_wallet_payload() {
2665 let projection = tip_projection(None, "alice@example.com");
2666 let view = projection.as_view();
2667 let first = TipHandoff::new(&projection).unwrap();
2668 let second = TipHandoff::new(&projection).unwrap();
2669 let html = render_tip_cta(&first).into_string();
2670
2671 assert!(html.contains("Send a tip to alice@example.com."));
2672 assert!(html.contains(&format!("href=\"{}\"", view.wallet_link)));
2673 assert!(html.contains(&format!("data-lnurl=\"{}\"", view.lnurl)));
2674 assert_eq!(first.qr.0, second.qr.0);
2675 let code = QrCode::new(view.lnurl.as_bytes()).unwrap();
2676 let mut dark_modules = 0;
2677 for y in 0..code.width() {
2678 for x in 0..code.width() {
2679 if code[(x, y)] == Color::Dark {
2680 dark_modules += 1;
2681 assert!(
2682 first
2683 .qr
2684 .0
2685 .contains(&format!("M{} {}h1v1h-1z", x + 4, y + 4))
2686 );
2687 }
2688 }
2689 }
2690 assert_eq!(first.qr.0.matches('z').count(), dark_modules);
2691 }
2692
2693 #[test]
2694 fn authored_tip_policy_controls_the_profile_handoff() {
2695 let fixture = fixture();
2696 let rendered = fixture
2697 .catalog
2698 .current_post(&PostId::parse(FIRST_ID).unwrap())
2699 .unwrap();
2700 let mut publication = fixture.catalog.publication.clone();
2701 let canonical_url = CanonicalSiteUrl::for_path(
2702 &publication.site.base_url,
2703 &PublicPagePath::post(&rendered.document.metadata.slug),
2704 );
2705 let projection = tip_projection(Some("Alice"), "alice@example.com");
2706 let handoff = TipHandoff::new(&projection).unwrap();
2707 let mut page = PostPageView::from_rendered(rendered, None);
2708
2709 publication.tips = DefaultPostTipPolicy::Disabled;
2710 page.tips = PostTipPolicy::InheritPublication;
2711 let inherited_disabled = render_post(
2712 &PageRenderer::new(
2713 &publication,
2714 embedded_manifest(),
2715 &fixture.catalog.site_assets,
2716 HeadAssetProjection::Identity,
2717 )
2718 .unwrap(),
2719 page,
2720 &canonical_url,
2721 ArticleBody::Omitted,
2722 PostNavigation::default(),
2723 Some(&handoff),
2724 )
2725 .unwrap()
2726 .into_string();
2727 assert!(!inherited_disabled.contains("class=\"tip-cta\""));
2728
2729 page.tips = PostTipPolicy::Enabled;
2730 let post_enabled = render_post(
2731 &PageRenderer::new(
2732 &publication,
2733 embedded_manifest(),
2734 &fixture.catalog.site_assets,
2735 HeadAssetProjection::Identity,
2736 )
2737 .unwrap(),
2738 page,
2739 &canonical_url,
2740 ArticleBody::Omitted,
2741 PostNavigation::default(),
2742 Some(&handoff),
2743 )
2744 .unwrap()
2745 .into_string();
2746 assert!(post_enabled.contains("class=\"tip-cta\""));
2747
2748 publication.tips = DefaultPostTipPolicy::Enabled;
2749 page.tips = PostTipPolicy::Disabled;
2750 let post_disabled = render_post(
2751 &PageRenderer::new(
2752 &publication,
2753 embedded_manifest(),
2754 &fixture.catalog.site_assets,
2755 HeadAssetProjection::Identity,
2756 )
2757 .unwrap(),
2758 page,
2759 &canonical_url,
2760 ArticleBody::Omitted,
2761 PostNavigation::default(),
2762 Some(&handoff),
2763 )
2764 .unwrap()
2765 .into_string();
2766 assert!(!post_disabled.contains("class=\"tip-cta\""));
2767 }
2768
2769 #[test]
2770 fn exact_public_selection_controls_pages_chronology_and_reachable_assets() {
2771 let fixture = fixture();
2772 let ledger = projection([entry(&fixture, FIRST_ID, 2_000)]);
2773 let snapshot = build_snapshot(&fixture, &ledger).unwrap();
2774
2775 let first_slug = PostSlug::parse("first-post").unwrap();
2776 let second_slug = PostSlug::parse("second-post").unwrap();
2777 let draft_slug = PostSlug::parse("draft-post").unwrap();
2778 let page = snapshot.post_page(&first_slug).unwrap();
2779 assert!(page.contains("First <script>alert(1)</script>"));
2780 assert!(page.contains("<script>alert(\'body\')</script>"));
2781 assert!(!page.contains("<script>"));
2782 assert!(page.contains("1970-01-01 0:33:20.0 +00:00:00"));
2783 assert!(snapshot.post_page(&second_slug).is_none());
2784 assert!(snapshot.post_page(&draft_slug).is_none());
2785 assert!(
2786 snapshot
2787 .tag_page(&PostTag::parse("rust").unwrap())
2788 .is_some()
2789 );
2790 assert!(
2791 snapshot
2792 .tag_page(&PostTag::parse("draft-tag").unwrap())
2793 .is_none()
2794 );
2795 assert_eq!(
2796 snapshot.post_canonical_url(&first_slug).unwrap().as_str(),
2797 "https://blog.example.com/posts/first-post"
2798 );
2799
2800 let paths: Vec<_> = [
2801 "assets/favicon.png",
2802 "assets/first-cover.png",
2803 "assets/public.png",
2804 ]
2805 .map(|path| {
2806 SnapshotAssetPath::new(&snapshot.digest, &LogicalAssetPath::parse(path).unwrap())
2807 .unwrap()
2808 })
2809 .into();
2810 assert_eq!(snapshot.assets.keys().cloned().collect::<Vec<_>>(), paths);
2811 assert!(!snapshot.index_page().contains("Second post"));
2812 assert!(!snapshot.index_page().contains("Draft post"));
2813 assert!(snapshot.feed.body.contains(FIRST_ID));
2814 assert!(
2815 snapshot
2816 .feed
2817 .body
2818 .contains("https://blog.example.com/posts/first-post")
2819 );
2820 assert!(!snapshot.feed.body.contains(SECOND_ID));
2821 assert!(!snapshot.feed.body.contains(DRAFT_ID));
2822 assert_eq!(
2823 snapshot.robots.body.as_ref(),
2824 concat!(
2825 "User-agent: *\n",
2826 "Allow: /\n",
2827 "\n",
2828 "Sitemap: https://blog.example.com/sitemap.xml\n",
2829 )
2830 );
2831 assert!(
2832 snapshot
2833 .sitemap
2834 .body
2835 .contains("https://blog.example.com/posts/first-post")
2836 );
2837 assert!(
2838 snapshot
2839 .sitemap
2840 .body
2841 .contains("https://blog.example.com/tags/rust")
2842 );
2843 assert!(!snapshot.sitemap.body.contains("second-post"));
2844 assert!(!snapshot.sitemap.body.contains("draft-post"));
2845 assert!(!snapshot.sitemap.body.contains("draft-tag"));
2846 }
2847
2848 #[test]
2849 fn canonical_pages_render_exact_core_open_graph_and_blog_posting_metadata() {
2850 let fixture = fixture();
2851 let ledger = projection([
2852 entry(&fixture, FIRST_ID, 2_000),
2853 entry(&fixture, SECOND_ID, 3_000),
2854 ]);
2855 let snapshot = build_snapshot(&fixture, &ledger).unwrap();
2856
2857 assert_core_page_metadata(
2858 &snapshot.index_page(),
2859 "Site <unsafe> & title",
2860 "A <careful> site.",
2861 "https://blog.example.com/",
2862 "website",
2863 );
2864 assert_core_page_metadata(
2865 &snapshot.archive_page(),
2866 "Archive",
2867 "Browse every published post from Site <unsafe> & title.",
2868 "https://blog.example.com/archive",
2869 "website",
2870 );
2871 let tag_page = snapshot.tag_page(&PostTag::parse("rust").unwrap()).unwrap();
2872 assert_core_page_metadata(
2873 &tag_page,
2874 "Posts tagged rust",
2875 "Browse published posts tagged “rust” on Site <unsafe> & title.",
2876 "https://blog.example.com/tags/rust",
2877 "website",
2878 );
2879
2880 let first_page = snapshot
2881 .post_page(&PostSlug::parse("first-post").unwrap())
2882 .unwrap();
2883 assert_core_page_metadata(
2884 &first_page,
2885 "First <script>alert(1)</script>",
2886 "Description <unsafe> & text.",
2887 "https://blog.example.com/posts/first-post",
2888 "article",
2889 );
2890 assert!(first_page.contains(
2891 "<meta property=\"article:published_time\" content=\"1970-01-01T00:33:20Z\">"
2892 ));
2893 assert!(first_page.contains(
2894 "<meta property=\"article:modified_time\" content=\"2026-08-29T16:00:00-04:00\">"
2895 ));
2896 assert_eq!(first_page.matches("property=\"article:tag\"").count(), 1);
2897 assert!(first_page.contains("<meta property=\"article:tag\" content=\"rust\">"));
2898
2899 let document = post_json_ld(&first_page);
2900 assert_eq!(document["@context"], "https://schema.org");
2901 assert_eq!(document["@type"], "BlogPosting");
2902 assert_eq!(document["headline"], "First <script>alert(1)</script>");
2903 assert_eq!(document["description"], "Description <unsafe> & text.");
2904 assert_eq!(document["url"], "https://blog.example.com/posts/first-post");
2905 assert_eq!(document["mainEntityOfPage"], document["url"]);
2906 assert_eq!(document["dateCreated"], "2026-08-29T15:00:00-04:00");
2907 assert_eq!(document["datePublished"], "1970-01-01T00:33:20Z");
2908 assert_eq!(document["dateModified"], "2026-08-29T16:00:00-04:00");
2909 assert_eq!(document["author"]["@type"], "Person");
2910 assert_eq!(document["author"]["name"], "Author <unsafe>");
2911 assert_eq!(document["keywords"], serde_json::json!(["rust"]));
2912 assert_eq!(
2913 document["image"],
2914 format!(
2915 "https://blog.example.com/assets/{}/first-cover.png",
2916 snapshot.digest
2917 )
2918 );
2919
2920 let second_page = snapshot
2921 .post_page(&PostSlug::parse("second-post").unwrap())
2922 .unwrap();
2923 assert_eq!(second_page.matches("property=\"article:tag\"").count(), 2);
2924 assert!(second_page.contains("<meta property=\"article:tag\" content=\"rust\">"));
2925 assert!(second_page.contains("<meta property=\"article:tag\" content=\"sqlite\">"));
2926 assert_eq!(
2927 post_json_ld(&second_page)["keywords"],
2928 serde_json::json!(["rust", "sqlite"])
2929 );
2930
2931 for error_page in [
2932 snapshot.not_found_page(),
2933 snapshot.method_not_allowed_page(),
2934 ] {
2935 assert!(!error_page.contains("<link rel=\"canonical\""));
2936 assert!(!error_page.contains("<meta property=\"og:"));
2937 assert!(!error_page.contains("application/ld+json"));
2938 }
2939 }
2940
2941 #[test]
2942 fn rss_failure_rejects_the_candidate_without_changing_the_active_snapshot() {
2943 let valid = fixture();
2944 let ledger = projection([entry(&valid, FIRST_ID, 2_000)]);
2945 let active = build_snapshot(&valid, &ledger).unwrap();
2946 let (reader, _activator) = snapshot_store(active);
2947 let before = reader.load_full();
2948
2949 let mut invalid = fixture();
2950 Arc::make_mut(&mut invalid.catalog).publication.site.title =
2951 markdown_compiler::SiteTitle::new("Invalid RSS \u{fffe}").unwrap();
2952 let error = build_snapshot(&invalid, &ledger).unwrap_err();
2953
2954 assert_eq!(error.code, SiteSnapshotBuildErrorCode::RssRenderFailed);
2955 assert_eq!(error.post_id, None);
2956 assert!(Arc::ptr_eq(&before, &reader.load_full()));
2957 }
2958
2959 #[test]
2960 fn sitemap_failure_rejects_the_candidate_without_changing_the_active_snapshot() {
2961 let valid = fixture();
2962 let ledger = projection([entry(&valid, FIRST_ID, 2_000)]);
2963 let active = build_snapshot(&valid, &ledger).unwrap();
2964 let (reader, _activator) = snapshot_store(active);
2965 let before = reader.load_full();
2966
2967 let oversized_origin = format!("https://{}example.com/", "a.".repeat(1_024));
2968 let base_url = markdown_compiler::PublicationBaseUrl::parse(&oversized_origin).unwrap();
2969 assert!(base_url.as_str().chars().count() >= 2_048);
2970 let mut invalid = fixture();
2971 Arc::make_mut(&mut invalid.catalog)
2972 .publication
2973 .site
2974 .base_url = base_url;
2975 let error = build_snapshot(&invalid, &ledger).unwrap_err();
2976
2977 assert_eq!(error.code, SiteSnapshotBuildErrorCode::SitemapRenderFailed);
2978 assert_eq!(error.post_id, None);
2979 assert!(Arc::ptr_eq(&before, &reader.load_full()));
2980 }
2981
2982 #[test]
2983 fn robots_failure_rejects_the_candidate_without_changing_the_active_snapshot() {
2984 let valid = fixture();
2985 let ledger = PublicLedgerProjection::empty();
2986 let active = build_snapshot(&valid, &ledger).unwrap();
2987 let (reader, _activator) = snapshot_store(active);
2988 let before = reader.load_full();
2989
2990 let oversized_origin = format!("https://{}bexample.com/", "a.".repeat(1_008));
2991 let base_url = markdown_compiler::PublicationBaseUrl::parse(&oversized_origin).unwrap();
2992 assert_eq!(
2993 CanonicalSiteUrl::for_path(&base_url, &PublicPagePath::sitemap())
2994 .as_str()
2995 .chars()
2996 .count(),
2997 2_048
2998 );
2999 let mut invalid = fixture();
3000 Arc::make_mut(&mut invalid.catalog)
3001 .publication
3002 .site
3003 .base_url = base_url;
3004 let error = build_snapshot(&invalid, &ledger).unwrap_err();
3005
3006 assert_eq!(error.code, SiteSnapshotBuildErrorCode::RobotsRenderFailed);
3007 assert_eq!(error.post_id, None);
3008 assert!(Arc::ptr_eq(&before, &reader.load_full()));
3009 }
3010
3011 #[test]
3012 fn mixed_ledger_projects_retained_body_and_assets_with_current_revisions() {
3013 let prior = fixture_with_first(
3014 "# Retained body\n\n",
3015 b"retained public bytes",
3016 );
3017 let mut current = fixture_with_first(
3018 "# Current unpublished body\n\n",
3019 b"current public bytes",
3020 );
3021 let retained_first = entry(&prior, FIRST_ID, 1_000);
3022 let current_second = entry(¤t, SECOND_ID, 2_000);
3023 let ledger = projection([retained_first, current_second]);
3024 Arc::make_mut(&mut current.catalog)
3025 .retain_revisions_from(&prior.catalog, ledger.revision_keys())
3026 .unwrap();
3027
3028 let snapshot = build_snapshot(¤t, &ledger).unwrap();
3029 let first = snapshot
3030 .post_page(&PostSlug::parse("first-post").unwrap())
3031 .unwrap();
3032 assert!(first.contains("Retained body"));
3033 assert!(!first.contains("Current unpublished body"));
3034 let second = snapshot
3035 .post_page(&PostSlug::parse("second-post").unwrap())
3036 .unwrap();
3037 assert!(second.contains("Second"));
3038 let retained_asset = snapshot
3039 .public_asset(
3040 &SnapshotAssetPath::new(
3041 &snapshot.digest,
3042 &LogicalAssetPath::parse("assets/public.png").unwrap(),
3043 )
3044 .unwrap(),
3045 )
3046 .unwrap();
3047 assert_eq!(retained_asset.bytes.as_ref(), b"retained public bytes");
3048 }
3049
3050 #[test]
3051 fn mixed_retained_revisions_reject_alias_route_collisions_without_touching_the_active_snapshot()
3052 {
3053 for (second_slug, second_aliases) in [
3054 ("shared-route", &["current-second-alias"][..]),
3055 ("current-second", &["shared-route"][..]),
3056 ] {
3057 let prior = fixture_with_routes(
3058 "# Prior first\n",
3059 b"prior public bytes",
3060 "prior-first",
3061 &["shared-route"],
3062 "prior-second",
3063 &["prior-second-alias"],
3064 );
3065 let mut current = fixture_with_routes(
3066 "# Current first\n",
3067 b"current public bytes",
3068 "current-first",
3069 &["current-first-alias"],
3070 second_slug,
3071 second_aliases,
3072 );
3073 let ledger = projection([
3074 entry(&prior, FIRST_ID, 1_000),
3075 entry(¤t, SECOND_ID, 2_000),
3076 ]);
3077 Arc::make_mut(&mut current.catalog)
3078 .retain_revisions_from(&prior.catalog, ledger.revision_keys())
3079 .unwrap();
3080
3081 let active = build_snapshot(¤t, &PublicLedgerProjection::empty()).unwrap();
3082 let (reader, _activator) = snapshot_store(active);
3083 let before = reader.load_full();
3084 let error =
3085 render_site_shell(Arc::clone(¤t.catalog), embedded_manifest(), &ledger)
3086 .unwrap_err();
3087
3088 assert_eq!(error.code, SiteSnapshotBuildErrorCode::RouteCollision);
3089 assert!(Arc::ptr_eq(&before, &reader.load_full()));
3090 }
3091 }
3092
3093 #[test]
3094 fn snapshot_activation_replaces_canonical_and_authored_alias_routes_together() {
3095 let prior = fixture_with_routes(
3096 "# Prior first\n",
3097 b"prior public bytes",
3098 "first-post",
3099 &["original-first-post"],
3100 "second-post",
3101 &["original-second-post"],
3102 );
3103 let current = fixture_with_routes(
3104 "# Current first\n",
3105 b"current public bytes",
3106 "renamed-first-post",
3107 &["first-post"],
3108 "second-post",
3109 &["original-second-post"],
3110 );
3111 let prior_ledger = projection([entry(&prior, FIRST_ID, 1_000)]);
3112 let current_ledger = projection([entry(¤t, FIRST_ID, 1_000)]);
3113 let old = build_snapshot(&prior, &prior_ledger).unwrap();
3114 let old_digest = old.digest.clone();
3115 let next = build_snapshot(¤t, ¤t_ledger).unwrap();
3116 let (reader, mut activator) = snapshot_store(old);
3117
3118 let observed = reader.load_full();
3119 assert!(
3120 observed
3121 .post_page(&PostSlug::parse("first-post").unwrap())
3122 .is_some()
3123 );
3124 assert!(
3125 observed
3126 .alias_target(&PostAlias::parse("first-post").unwrap())
3127 .is_none()
3128 );
3129
3130 assert_eq!(
3131 activator.activate(&old_digest, next).unwrap(),
3132 SnapshotActivationOutcome::Activated
3133 );
3134 let observed = reader.load_full();
3135 assert!(
3136 observed
3137 .post_page(&PostSlug::parse("first-post").unwrap())
3138 .is_none()
3139 );
3140 assert!(
3141 observed
3142 .post_page(&PostSlug::parse("renamed-first-post").unwrap())
3143 .is_some()
3144 );
3145 assert_eq!(
3146 observed
3147 .alias_target(&PostAlias::parse("first-post").unwrap())
3148 .unwrap()
3149 .as_str(),
3150 "https://blog.example.com/posts/renamed-first-post"
3151 );
3152 assert!(
3153 observed
3154 .alias_target(&PostAlias::parse("original-first-post").unwrap())
3155 .is_none()
3156 );
3157 }
3158
3159 #[test]
3160 fn asset_collection_covers_each_source_dedupes_and_fails_closed() {
3161 let fixture = fixture();
3162 let favicon = catalog_asset(&fixture, "assets/favicon.png");
3163 let shared_reference = catalog_asset(&fixture, "assets/public.png");
3164 let site_assets = ResolvedSiteAssets::new(
3165 &fixture.catalog.publication,
3166 Some(AssetRevisionReference::local(favicon.clone())),
3167 None,
3168 Vec::new(),
3169 vec![AssetRevisionReference::local(shared_reference.clone())],
3170 );
3171 let mut selected = SelectedAssets::new();
3172 collect_site_global_assets(&mut selected, &site_assets, &fixture.catalog.local_assets)
3173 .unwrap();
3174
3175 let first_id = PostId::parse(FIRST_ID).unwrap();
3176 let first = fixture
3177 .catalog
3178 .get(&first_id, &fixture.revisions[&first_id])
3179 .unwrap();
3180 let first_cover = catalog_asset(&fixture, "assets/first-cover.png");
3181 let post_assets = ResolvedPostAssets::new(
3182 &first.document,
3183 Some(AssetRevisionReference::local(first_cover)),
3184 vec![AssetRevisionReference::local(shared_reference)],
3185 );
3186 collect_selected_post_assets(&mut selected, &post_assets, &fixture.catalog.local_assets)
3187 .unwrap();
3188
3189 assert_eq!(
3190 selected.by_path.len(),
3191 3,
3192 "the repeated post reference must dedupe"
3193 );
3194 let digest = SiteSnapshotDigest::parse(&format!("site-b3-v1-{}", "44".repeat(32))).unwrap();
3195 let public = materialize_public_assets(selected, &digest).unwrap();
3196 let paths: Vec<_> = [
3197 "assets/favicon.png",
3198 "assets/first-cover.png",
3199 "assets/public.png",
3200 ]
3201 .map(|path| {
3202 SnapshotAssetPath::new(&digest, &LogicalAssetPath::parse(path).unwrap()).unwrap()
3203 })
3204 .into();
3205 assert_eq!(public.keys().cloned().collect::<Vec<_>>(), paths);
3206 let authored_path = SnapshotAssetPath::new(
3207 &digest,
3208 &LogicalAssetPath::parse("assets/public.png").unwrap(),
3209 )
3210 .unwrap();
3211 let authored_png = public.get(&authored_path).unwrap();
3212 assert_eq!(
3213 authored_png.digest,
3214 catalog_asset(&fixture, "assets/public.png").digest
3215 );
3216 assert_eq!(authored_png.delivery.content_type(), "image/png");
3217 assert!(matches!(authored_png.delivery, AssetDelivery::Inline(_)));
3218 let missing = DigestedAsset::new(
3219 LogicalAssetPath::parse("assets/missing.png").unwrap(),
3220 digest_asset(b"missing"),
3221 );
3222 let error = insert_authored_asset(
3223 &mut SelectedAssets::new(),
3224 &missing,
3225 &fixture.catalog.local_assets,
3226 )
3227 .unwrap_err();
3228 assert_eq!(error.code, SiteSnapshotBuildErrorCode::AssetUnavailable);
3229 assert!(error.message.contains("not present"));
3230
3231 let mismatched = DigestedAsset::new(favicon.path.clone(), digest_asset(b"changed"));
3232 let error = insert_authored_asset(
3233 &mut SelectedAssets::new(),
3234 &mismatched,
3235 &fixture.catalog.local_assets,
3236 )
3237 .unwrap_err();
3238 assert_eq!(error.code, SiteSnapshotBuildErrorCode::AssetUnavailable);
3239 assert!(error.message.contains("does not match"));
3240
3241 let mut collision = SelectedAssets::new();
3242 insert_authored_asset(&mut collision, &favicon, &fixture.catalog.local_assets).unwrap();
3243 let conflicting = DigestedAsset::new(favicon.path.clone(), digest_asset(b"conflicting"));
3244 let error = collision
3245 .insert(conflicting, Arc::from(&b"conflicting"[..]))
3246 .unwrap_err();
3247 assert_eq!(error.code, SiteSnapshotBuildErrorCode::AssetCollision);
3248 }
3249
3250 #[test]
3251 fn chronology_uses_ledger_time_then_stable_post_id() {
3252 let fixture = fixture();
3253 let ledger = projection([
3254 entry(&fixture, FIRST_ID, 1_000),
3255 entry(&fixture, SECOND_ID, 2_000),
3256 ]);
3257 let snapshot = build_snapshot(&fixture, &ledger).unwrap();
3258 let index = snapshot.index_page();
3259 assert!(index.find("Second post").unwrap() < index.find("First <script>").unwrap());
3260 assert!(
3261 snapshot.feed.body.find(SECOND_ID).unwrap()
3262 < snapshot.feed.body.find(FIRST_ID).unwrap()
3263 );
3264
3265 let reversed = projection([
3266 entry(&fixture, SECOND_ID, 2_000),
3267 entry(&fixture, FIRST_ID, 1_000),
3268 ]);
3269 let rebuilt = build_snapshot(&fixture, &reversed).unwrap();
3270 assert_eq!(snapshot.digest, rebuilt.digest);
3271 assert_eq!(snapshot.index_page(), rebuilt.index_page());
3272 assert_eq!(snapshot.feed.body, rebuilt.feed.body);
3273 assert_eq!(snapshot.feed.digest, rebuilt.feed.digest);
3274 assert_eq!(snapshot.robots.body, rebuilt.robots.body);
3275 assert_eq!(snapshot.robots.digest, rebuilt.robots.digest);
3276 assert_eq!(snapshot.sitemap.body, rebuilt.sitemap.body);
3277 assert_eq!(snapshot.sitemap.digest, rebuilt.sitemap.digest);
3278
3279 let tied = projection([
3280 entry(&fixture, SECOND_ID, 3_000),
3281 entry(&fixture, FIRST_ID, 3_000),
3282 ]);
3283 let tied = build_snapshot(&fixture, &tied).unwrap();
3284 assert!(
3285 tied.index_page().find("First <script>").unwrap()
3286 < tied.index_page().find("Second post").unwrap()
3287 );
3288 assert!(tied.feed.body.find(FIRST_ID).unwrap() < tied.feed.body.find(SECOND_ID).unwrap());
3289 }
3290
3291 #[test]
3292 fn chronological_neighbors_cover_both_boundaries_and_the_middle() {
3293 assert_eq!(
3294 chronological_neighbors(3, &[2, 0, 1]),
3295 vec![
3296 ChronologicalNeighbors {
3297 previous: Some(1),
3298 next: Some(2),
3299 },
3300 ChronologicalNeighbors {
3301 previous: None,
3302 next: Some(0),
3303 },
3304 ChronologicalNeighbors {
3305 previous: Some(0),
3306 next: None,
3307 },
3308 ]
3309 );
3310 }
3311
3312 #[test]
3313 fn public_post_navigation_uses_only_canonical_chronological_neighbors() {
3314 let fixture = fixture();
3315 let ledger = projection([
3316 entry(&fixture, FIRST_ID, 1_000),
3317 entry(&fixture, SECOND_ID, 2_000),
3318 ]);
3319 let snapshot = build_snapshot(&fixture, &ledger).unwrap();
3320 let first = snapshot
3321 .post_page(&PostSlug::parse("first-post").unwrap())
3322 .unwrap();
3323 let second = snapshot
3324 .post_page(&PostSlug::parse("second-post").unwrap())
3325 .unwrap();
3326
3327 assert!(first.contains("class=\"maincopy-post-page\""));
3328 assert!(first.contains("maincopy-post-navigation-next"));
3329 assert!(first.contains("href=\"/posts/second-post\" rel=\"next\""));
3330 assert!(!first.contains("maincopy-post-navigation-previous"));
3331 assert!(!first.contains("draft-post"));
3332
3333 assert!(second.contains("maincopy-post-navigation-previous"));
3334 assert!(second.contains("href=\"/posts/first-post\" rel=\"prev\""));
3335 assert!(second.contains("First <script>alert(1)</script>"));
3336 assert!(!second.contains("maincopy-post-navigation-next"));
3337 assert!(!second.contains("draft-post"));
3338
3339 let preview = render_post_preview(
3340 &fixture.catalog,
3341 embedded_manifest(),
3342 &PostId::parse(DRAFT_ID).unwrap(),
3343 "/api/admin/v1/preview-assets/navigation",
3344 None,
3345 )
3346 .unwrap()
3347 .unwrap();
3348 assert!(preview.contains("class=\"maincopy-post-page\""));
3349 assert!(!preview.contains("maincopy-post-navigation"));
3350 }
3351
3352 #[test]
3353 fn one_public_post_omits_empty_navigation() {
3354 let fixture = fixture();
3355 let ledger = projection([entry(&fixture, FIRST_ID, 2_000)]);
3356 let snapshot = build_snapshot(&fixture, &ledger).unwrap();
3357 let page = snapshot
3358 .post_page(&PostSlug::parse("first-post").unwrap())
3359 .unwrap();
3360 assert!(!page.contains("maincopy-post-navigation"));
3361 }
3362
3363 #[test]
3364 fn site_shell_identity_binds_exact_discovery_document_representations() {
3365 let fixture = fixture();
3366 let ledger = projection([entry(&fixture, FIRST_ID, 2_000)]);
3367 let posts = select_public_posts(&fixture.catalog, &ledger).unwrap();
3368 let chronology = chronology(&posts);
3369 let post_navigation = chronological_neighbors(posts.len(), &chronology);
3370 let tags = tag_index(&posts, &chronology);
3371 let redirects = alias_redirect_index(&posts).unwrap();
3372 let feed = render_public_feed(&fixture.catalog.publication, &posts, &chronology).unwrap();
3373 let sitemap = render_public_sitemap(&fixture.catalog.publication, &posts, &tags).unwrap();
3374 let robots = render_public_robots(&fixture.catalog.publication).unwrap();
3375 let original = render_pre_injection_shell(
3376 &PageRenderer::new(
3377 &fixture.catalog.publication,
3378 embedded_manifest(),
3379 &fixture.catalog.site_assets,
3380 HeadAssetProjection::Identity,
3381 )
3382 .unwrap(),
3383 PublicPagePlan {
3384 posts: &posts,
3385 chronology: &chronology,
3386 post_navigation: &post_navigation,
3387 tags: &tags,
3388 redirects: &redirects,
3389 },
3390 DiscoveryDocuments {
3391 feed: &feed,
3392 robots: &robots,
3393 sitemap: &sitemap,
3394 },
3395 )
3396 .unwrap();
3397 let mut changed_feed = feed.clone();
3398 changed_feed.body = format!("{}\n", feed.body).into();
3399 let feed_changed = render_pre_injection_shell(
3400 &PageRenderer::new(
3401 &fixture.catalog.publication,
3402 embedded_manifest(),
3403 &fixture.catalog.site_assets,
3404 HeadAssetProjection::Identity,
3405 )
3406 .unwrap(),
3407 PublicPagePlan {
3408 posts: &posts,
3409 chronology: &chronology,
3410 post_navigation: &post_navigation,
3411 tags: &tags,
3412 redirects: &redirects,
3413 },
3414 DiscoveryDocuments {
3415 feed: &changed_feed,
3416 robots: &robots,
3417 sitemap: &sitemap,
3418 },
3419 )
3420 .unwrap();
3421 let mut changed_robots = robots.clone();
3422 changed_robots.body = format!("{}\n", robots.body).into();
3423 let robots_changed = render_pre_injection_shell(
3424 &PageRenderer::new(
3425 &fixture.catalog.publication,
3426 embedded_manifest(),
3427 &fixture.catalog.site_assets,
3428 HeadAssetProjection::Identity,
3429 )
3430 .unwrap(),
3431 PublicPagePlan {
3432 posts: &posts,
3433 chronology: &chronology,
3434 post_navigation: &post_navigation,
3435 tags: &tags,
3436 redirects: &redirects,
3437 },
3438 DiscoveryDocuments {
3439 feed: &feed,
3440 robots: &changed_robots,
3441 sitemap: &sitemap,
3442 },
3443 )
3444 .unwrap();
3445 let mut changed_sitemap = sitemap.clone();
3446 changed_sitemap.body = format!("{}\n", sitemap.body).into();
3447 let sitemap_changed = render_pre_injection_shell(
3448 &PageRenderer::new(
3449 &fixture.catalog.publication,
3450 embedded_manifest(),
3451 &fixture.catalog.site_assets,
3452 HeadAssetProjection::Identity,
3453 )
3454 .unwrap(),
3455 PublicPagePlan {
3456 posts: &posts,
3457 chronology: &chronology,
3458 post_navigation: &post_navigation,
3459 tags: &tags,
3460 redirects: &redirects,
3461 },
3462 DiscoveryDocuments {
3463 feed: &feed,
3464 robots: &robots,
3465 sitemap: &changed_sitemap,
3466 },
3467 )
3468 .unwrap();
3469 let mut changed_redirects = redirects.clone();
3470 changed_redirects.insert(
3471 PostAlias::parse("original-first-post").unwrap(),
3472 Arc::new(CanonicalSiteUrl::for_path(
3473 &fixture.catalog.publication.site.base_url,
3474 &PublicPagePath::post(&PostSlug::parse("changed-target").unwrap()),
3475 )),
3476 );
3477 let redirects_changed = render_pre_injection_shell(
3478 &PageRenderer::new(
3479 &fixture.catalog.publication,
3480 embedded_manifest(),
3481 &fixture.catalog.site_assets,
3482 HeadAssetProjection::Identity,
3483 )
3484 .unwrap(),
3485 PublicPagePlan {
3486 posts: &posts,
3487 chronology: &chronology,
3488 post_navigation: &post_navigation,
3489 tags: &tags,
3490 redirects: &changed_redirects,
3491 },
3492 DiscoveryDocuments {
3493 feed: &feed,
3494 robots: &robots,
3495 sitemap: &sitemap,
3496 },
3497 )
3498 .unwrap();
3499
3500 assert_ne!(original, feed_changed);
3501 assert_ne!(original, robots_changed);
3502 assert_ne!(original, sitemap_changed);
3503 assert_ne!(original, redirects_changed);
3504 }
3505
3506 #[test]
3507 fn presentation_identity_binds_exact_discovery_document_representations() {
3508 let fixture = fixture();
3509 let ledger = projection([entry(&fixture, FIRST_ID, 2_000)]);
3510 let snapshot = build_snapshot(&fixture, &ledger).unwrap();
3511 let mut changed_robots = snapshot.robots.clone();
3512 changed_robots.body = format!("{}\n", snapshot.robots.body).into();
3513 let mut changed_sitemap = snapshot.sitemap.clone();
3514 changed_sitemap.body = format!("{}\n", snapshot.sitemap.body).into();
3515 let mut changed_redirects = snapshot.redirects.clone();
3516 changed_redirects.insert(
3517 PostAlias::parse("original-first-post").unwrap(),
3518 Arc::new(CanonicalSiteUrl::for_path(
3519 &fixture.catalog.publication.site.base_url,
3520 &PublicPagePath::post(&PostSlug::parse("changed-target").unwrap()),
3521 )),
3522 );
3523
3524 let robots_changed = presentation_digest(
3525 &snapshot.pages,
3526 &snapshot.redirects,
3527 &snapshot.not_found,
3528 &snapshot.method_not_allowed,
3529 &snapshot.feed,
3530 &changed_robots,
3531 &snapshot.sitemap,
3532 );
3533 let sitemap_changed = presentation_digest(
3534 &snapshot.pages,
3535 &snapshot.redirects,
3536 &snapshot.not_found,
3537 &snapshot.method_not_allowed,
3538 &snapshot.feed,
3539 &snapshot.robots,
3540 &changed_sitemap,
3541 );
3542 let redirects_changed = presentation_digest(
3543 &snapshot.pages,
3544 &changed_redirects,
3545 &snapshot.not_found,
3546 &snapshot.method_not_allowed,
3547 &snapshot.feed,
3548 &snapshot.robots,
3549 &snapshot.sitemap,
3550 );
3551
3552 assert_ne!(snapshot.presentation_digest, robots_changed);
3553 assert_ne!(snapshot.presentation_digest, sitemap_changed);
3554 assert_ne!(snapshot.presentation_digest, redirects_changed);
3555 }
3556
3557 #[test]
3558 fn profile_only_presentation_change_activates_without_changing_content_identity() {
3559 let fixture = fixture();
3560 let post_id = PostId::parse(FIRST_ID).unwrap();
3561 let ledger = projection([entry(&fixture, FIRST_ID, 2_000)]);
3562 let old = build_snapshot(&fixture, &ledger).unwrap();
3563 let content_digest = old.digest.clone();
3564 let old_presentation = old.presentation_digest;
3565 let old_feed_body = Arc::clone(&old.feed.body);
3566 let old_feed_digest = old.feed.digest;
3567 let old_robots_body = Arc::clone(&old.robots.body);
3568 let old_robots_digest = old.robots.digest;
3569 let old_sitemap_body = Arc::clone(&old.sitemap.body);
3570 let old_sitemap_digest = old.sitemap.digest;
3571 let revision = fixture.revisions[&post_id].clone();
3572 let mut next = build_snapshot(&fixture, &ledger).unwrap();
3573 let projection = tip_projection(Some("Alice"), "alice@example.com");
3574 let handoff = TipHandoff::new(&projection).unwrap();
3575 let page = next
3576 .pages
3577 .get_mut(&PageRoute::Post(PostSlug::parse("first-post").unwrap()))
3578 .unwrap();
3579 let mut html = page.html.to_string();
3580 html.push_str(&render_tip_cta(&handoff).into_string());
3581 page.html = html.into();
3582 next.presentation_digest = presentation_digest(
3583 &next.pages,
3584 &next.redirects,
3585 &next.not_found,
3586 &next.method_not_allowed,
3587 &next.feed,
3588 &next.robots,
3589 &next.sitemap,
3590 );
3591
3592 assert_eq!(next.digest, content_digest);
3593 assert_eq!(fixture.revisions[&post_id], revision);
3594 assert_ne!(next.presentation_digest, old_presentation);
3595 assert_eq!(next.feed.body, old_feed_body);
3596 assert_eq!(next.feed.digest, old_feed_digest);
3597 assert_eq!(next.robots.body, old_robots_body);
3598 assert_eq!(next.robots.digest, old_robots_digest);
3599 assert_eq!(next.sitemap.body, old_sitemap_body);
3600 assert_eq!(next.sitemap.digest, old_sitemap_digest);
3601
3602 let (reader, mut activator) = snapshot_store(old);
3603 assert_eq!(
3604 activator.activate(&content_digest, next).unwrap(),
3605 SnapshotActivationOutcome::Activated
3606 );
3607 assert_ne!(reader.load_full().presentation_digest, old_presentation);
3608 assert_eq!(reader.load_full().digest, content_digest);
3609 }
3610
3611 #[test]
3612 fn missing_and_draft_revisions_fail_closed() {
3613 let fixture = fixture();
3614 let missing_id = PostId::parse(FIRST_ID).unwrap();
3615 let missing = projection([PublishedPostRevision::new(
3616 missing_id,
3617 PostRevisionDigest::parse(&format!("post-b3-v1-{}", "55".repeat(32))).unwrap(),
3618 at(1_000),
3619 )]);
3620 assert_eq!(
3621 render_site_shell(Arc::clone(&fixture.catalog), embedded_manifest(), &missing)
3622 .unwrap_err()
3623 .code,
3624 SiteSnapshotBuildErrorCode::RevisionUnavailable
3625 );
3626
3627 let draft = projection([entry(&fixture, DRAFT_ID, 1_000)]);
3628 assert_eq!(
3629 render_site_shell(Arc::clone(&fixture.catalog), embedded_manifest(), &draft)
3630 .unwrap_err()
3631 .code,
3632 SiteSnapshotBuildErrorCode::DraftSelected
3633 );
3634 }
3635
3636 #[test]
3637 fn snapshot_identity_and_presentation_preserve_canonical_bytes() {
3638 let fixture = fixture();
3639 let ledger = projection([
3640 entry(&fixture, FIRST_ID, 1_000),
3641 entry(&fixture, SECOND_ID, 2_000),
3642 ]);
3643 let snapshot = build_snapshot(&fixture, &ledger).unwrap();
3644 assert_eq!(
3645 snapshot.digest.to_string(),
3646 "site-b3-v1-d58571601459e2f96420e12d8d9e85b15181c181de9767199f45a8d7138e8b66"
3647 );
3648 assert_eq!(
3649 snapshot.presentation_digest,
3650 PresentationDigest([
3651 39, 95, 147, 214, 124, 121, 50, 143, 144, 211, 77, 97, 90, 187, 16, 237, 128, 32,
3652 116, 127, 146, 53, 247, 251, 54, 131, 33, 62, 98, 255, 165, 144,
3653 ])
3654 );
3655 }
3656
3657 #[test]
3658 fn snapshot_uses_the_ledger_bound_before_the_caller_changes_it() {
3659 let fixture = fixture();
3660 let mut ledger = projection([entry(&fixture, FIRST_ID, 1_000)]);
3661 let expected = build_snapshot(&fixture, &ledger).unwrap();
3662 let shell =
3663 render_site_shell(Arc::clone(&fixture.catalog), embedded_manifest(), &ledger).unwrap();
3664 ledger = projection([entry(&fixture, SECOND_ID, 1_000)]);
3665
3666 let snapshot = shell.into_snapshot().unwrap();
3667 assert_eq!(snapshot.digest, expected.digest);
3668 assert_eq!(snapshot.presentation_digest, expected.presentation_digest);
3669 assert_ne!(
3670 snapshot.digest,
3671 build_snapshot(&fixture, &ledger).unwrap().digest
3672 );
3673 }
3674
3675 #[test]
3676 fn shell_and_snapshot_limits_are_inclusive() {
3677 assert!(validate_page_size(MAX_PAGE_BYTES).is_ok());
3678 assert_eq!(
3679 validate_page_size(MAX_PAGE_BYTES + 1).unwrap_err().code,
3680 SiteSnapshotBuildErrorCode::PageLimitExceeded
3681 );
3682 assert!(validate_route_count(0, 0, MAX_PUBLIC_ROUTES - FIXED_PUBLIC_ROUTES).is_ok());
3683 assert_eq!(
3684 validate_route_count(0, 0, MAX_PUBLIC_ROUTES - FIXED_PUBLIC_ROUTES + 1)
3685 .unwrap_err()
3686 .code,
3687 SiteSnapshotBuildErrorCode::RouteLimitExceeded
3688 );
3689 let mut retained = RetainedHtmlBudget::new();
3690 retained.add(MAX_RETAINED_HTML_BYTES).unwrap();
3691 assert_eq!(
3692 retained.add(1).unwrap_err().code,
3693 SiteSnapshotBuildErrorCode::RetainedHtmlLimitExceeded
3694 );
3695
3696 assert_eq!(
3697 next_public_asset_bytes(MAX_PUBLIC_ASSETS - 1, 0, 0).unwrap(),
3698 0
3699 );
3700 assert_eq!(
3701 next_public_asset_bytes(MAX_PUBLIC_ASSETS, 0, 0)
3702 .unwrap_err()
3703 .code,
3704 SiteSnapshotBuildErrorCode::PublicAssetCountLimitExceeded
3705 );
3706
3707 assert_eq!(
3708 next_public_asset_bytes(0, 0, MAX_RETAINED_ASSET_BYTES).unwrap(),
3709 MAX_RETAINED_ASSET_BYTES
3710 );
3711 assert_eq!(
3712 next_public_asset_bytes(1, MAX_RETAINED_ASSET_BYTES, 1)
3713 .unwrap_err()
3714 .code,
3715 SiteSnapshotBuildErrorCode::RetainedAssetLimitExceeded
3716 );
3717 }
3718
3719 #[test]
3720 fn activation_checks_expected_digest_and_readers_never_observe_mixed_snapshots() {
3721 let fixture = fixture();
3722 let empty = PublicLedgerProjection::empty();
3723 let old = build_snapshot(&fixture, &empty).unwrap();
3724 let old_digest = old.digest.clone();
3725 let first = projection([entry(&fixture, FIRST_ID, 1_000)]);
3726 let new = build_snapshot(&fixture, &first).unwrap();
3727 let new_digest = new.digest.clone();
3728 let (reader, mut activator) = snapshot_store(old);
3729
3730 let wrong = SiteSnapshotDigest::parse(&format!("site-b3-v1-{}", "77".repeat(32))).unwrap();
3731 let replacement = build_snapshot(&fixture, &first).unwrap();
3732 let error = activator.activate(&wrong, replacement).unwrap_err();
3733 assert_eq!(error.expected, wrong);
3734 assert_eq!(error.actual, old_digest);
3735 assert_eq!(&reader.load_full().digest, &old_digest);
3736
3737 let barrier = Arc::new(Barrier::new(5));
3738 let mut readers = Vec::new();
3739 for _ in 0..4 {
3740 let reader = reader.clone();
3741 let barrier = Arc::clone(&barrier);
3742 let old_digest = old_digest.clone();
3743 let new_digest = new_digest.clone();
3744 readers.push(thread::spawn(move || {
3745 barrier.wait();
3746 for _ in 0..2_000 {
3747 let observed = reader.load_full();
3748 if observed.digest == old_digest {
3749 assert!(
3750 observed
3751 .post_page(&PostSlug::parse("first-post").unwrap())
3752 .is_none()
3753 );
3754 assert!(!observed.feed.body.contains(FIRST_ID));
3755 assert!(!observed.sitemap.body.contains("first-post"));
3756 } else if observed.digest == new_digest {
3757 assert!(
3758 observed
3759 .post_page(&PostSlug::parse("first-post").unwrap())
3760 .is_some()
3761 );
3762 assert!(observed.feed.body.contains(FIRST_ID));
3763 assert!(observed.sitemap.body.contains("first-post"));
3764 } else {
3765 panic!("reader observed an unknown snapshot");
3766 }
3767 }
3768 }));
3769 }
3770 barrier.wait();
3771 assert_eq!(
3772 activator.activate(&old_digest, new).unwrap(),
3773 SnapshotActivationOutcome::Activated
3774 );
3775 for reader in readers {
3776 reader.join().unwrap();
3777 }
3778 assert_eq!(&reader.load_full().digest, &new_digest);
3779 let same = build_snapshot(&fixture, &first).unwrap();
3780 assert_eq!(
3781 activator.activate(&new_digest, same).unwrap(),
3782 SnapshotActivationOutcome::AlreadyActive
3783 );
3784 }
3785
3786 #[test]
3787 fn activation_installs_a_new_content_identity_even_when_rendered_bytes_match() {
3788 let fixture = fixture();
3789 let ledger = PublicLedgerProjection::empty();
3790 let old = build_snapshot(&fixture, &ledger).unwrap();
3791 let old_digest = old.digest.clone();
3792 let old_presentation = old.presentation_digest;
3793 let mut next = build_snapshot(&fixture, &ledger).unwrap();
3794 let new_digest =
3795 SiteSnapshotDigest::parse(&format!("site-b3-v1-{}", "66".repeat(32))).unwrap();
3796 next.digest = new_digest.clone();
3797
3798 let (reader, mut activator) = snapshot_store(old);
3799 assert_eq!(next.presentation_digest, old_presentation);
3800 assert_eq!(
3801 activator.activate(&old_digest, next).unwrap(),
3802 SnapshotActivationOutcome::Activated
3803 );
3804 assert_eq!(reader.load_full().digest, new_digest);
3805 }
3806
3807 #[test]
3808 fn all_public_error_and_activation_enum_wire_names_are_stable() {
3809 for (code, name) in [
3810 (
3811 SiteSnapshotBuildErrorCode::FrontendManifestInvalid,
3812 "frontend_manifest_invalid",
3813 ),
3814 (
3815 SiteSnapshotBuildErrorCode::RevisionUnavailable,
3816 "revision_unavailable",
3817 ),
3818 (SiteSnapshotBuildErrorCode::DraftSelected, "draft_selected"),
3819 (
3820 SiteSnapshotBuildErrorCode::RouteCollision,
3821 "route_collision",
3822 ),
3823 (
3824 SiteSnapshotBuildErrorCode::RouteLimitExceeded,
3825 "route_limit_exceeded",
3826 ),
3827 (
3828 SiteSnapshotBuildErrorCode::PageLimitExceeded,
3829 "page_limit_exceeded",
3830 ),
3831 (
3832 SiteSnapshotBuildErrorCode::RetainedHtmlLimitExceeded,
3833 "retained_html_limit_exceeded",
3834 ),
3835 (
3836 SiteSnapshotBuildErrorCode::AssetUnavailable,
3837 "asset_unavailable",
3838 ),
3839 (
3840 SiteSnapshotBuildErrorCode::AssetCollision,
3841 "asset_collision",
3842 ),
3843 (
3844 SiteSnapshotBuildErrorCode::PublicAssetCountLimitExceeded,
3845 "public_asset_count_limit_exceeded",
3846 ),
3847 (
3848 SiteSnapshotBuildErrorCode::RetainedAssetLimitExceeded,
3849 "retained_asset_limit_exceeded",
3850 ),
3851 (
3852 SiteSnapshotBuildErrorCode::ArticleProjectionFailed,
3853 "article_projection_failed",
3854 ),
3855 (
3856 SiteSnapshotBuildErrorCode::RssRenderFailed,
3857 "rss_render_failed",
3858 ),
3859 (
3860 SiteSnapshotBuildErrorCode::RobotsRenderFailed,
3861 "robots_render_failed",
3862 ),
3863 (
3864 SiteSnapshotBuildErrorCode::SitemapRenderFailed,
3865 "sitemap_render_failed",
3866 ),
3867 (
3868 SiteSnapshotBuildErrorCode::MetadataRenderFailed,
3869 "metadata_render_failed",
3870 ),
3871 (
3872 SiteSnapshotBuildErrorCode::QrCodeGenerationFailed,
3873 "qr_code_generation_failed",
3874 ),
3875 (
3876 SiteSnapshotBuildErrorCode::IdentityRejected,
3877 "identity_rejected",
3878 ),
3879 ] {
3880 assert_eq!(serde_json::to_value(code).unwrap(), name);
3881 }
3882 assert_eq!(
3883 serde_json::to_value(SnapshotActivationOutcome::Activated).unwrap(),
3884 "activated"
3885 );
3886 assert_eq!(
3887 serde_json::to_value(SnapshotActivationOutcome::AlreadyActive).unwrap(),
3888 "already_active"
3889 );
3890 }
3891 fn image_fixture(favicon: &str, site_image: &str, article_image: &str) -> Fixture {
3892 let publication_source = format!(
3893 "[site]\ntitle = \"Images\"\nbase_url = \"https://blog.example.com/\"\ndescription = \"Image metadata.\"\nfavicon = {favicon:?}\nimage = {site_image:?}\n[author]\nname = \"Author\"\n[assets]\nallowed_https_origins = [\"https://cdn.example\"]\n"
3894 );
3895 let source = post_source(
3896 FIRST_ID,
3897 "Image post",
3898 PostRoutes {
3899 slug: "image-post",
3900 aliases: &[],
3901 },
3902 &["images"],
3903 Some(article_image),
3904 "Image metadata.",
3905 false,
3906 );
3907 let tree = content_tree(
3908 publication("publication.toml", publication_source),
3909 vec![post("posts/image.md", PostCollection::Posts, source)],
3910 ["favicon.png", "site.png", "article.png"]
3911 .into_iter()
3912 .map(|name| {
3913 asset(
3914 LogicalAssetPath::parse(&format!("assets/{name}")).unwrap(),
3915 name.as_bytes().to_vec(),
3916 )
3917 })
3918 .collect(),
3919 0,
3920 );
3921 let prepared = prepare_content(&tree).unwrap();
3922 let catalog = Arc::new(compile_content_catalog(&prepared).unwrap());
3923 let revisions = catalog
3924 .rendered_posts()
3925 .map(|post| (post.document.metadata.id.clone(), post.revision.clone()))
3926 .collect();
3927 Fixture { catalog, revisions }
3928 }
3929
3930 #[test]
3931 fn local_favicon_site_and_article_images_use_canonical_snapshot_urls() {
3932 let fixture = image_fixture(
3933 "assets/favicon.png",
3934 "assets/site.png",
3935 "assets/article.png",
3936 );
3937 let snapshot =
3938 build_snapshot(&fixture, &projection([entry(&fixture, FIRST_ID, 2_000)])).unwrap();
3939 let prefix = format!("https://blog.example.com/assets/{}/", snapshot.digest);
3940 let favicon = format!("<link rel=\"icon\" href=\"{prefix}favicon.png\">");
3941 for page in [
3942 snapshot.index_page(),
3943 snapshot.archive_page(),
3944 snapshot
3945 .tag_page(&PostTag::parse("images").unwrap())
3946 .unwrap(),
3947 ] {
3948 assert!(page.contains(&favicon));
3949 assert!(page.contains(&format!(
3950 "<meta property=\"og:image\" content=\"{prefix}site.png\">"
3951 )));
3952 }
3953 let article = snapshot
3954 .post_page(&PostSlug::parse("image-post").unwrap())
3955 .unwrap();
3956 assert!(article.contains(&favicon));
3957 assert!(article.contains(&format!(
3958 "<meta property=\"og:image\" content=\"{prefix}article.png\">"
3959 )));
3960 assert_eq!(
3961 post_json_ld(&article)["image"],
3962 format!("{prefix}article.png")
3963 );
3964 for name in ["favicon.png", "site.png", "article.png"] {
3965 let path =
3966 SnapshotAssetPath::parse(&format!("/assets/{}/{name}", snapshot.digest)).unwrap();
3967 assert!(snapshot.public_asset(&path).is_some());
3968 }
3969 let preview = render_post_preview(
3970 &fixture.catalog,
3971 embedded_manifest(),
3972 &PostId::parse(FIRST_ID).unwrap(),
3973 "/api/admin/v1/preview-assets/example",
3974 None,
3975 )
3976 .unwrap()
3977 .unwrap();
3978 assert!(preview.contains("<link rel=\"icon\" href=\"/api/admin/v1/preview-assets/example?path=assets/favicon.png\">"));
3979 assert!(!preview.contains("property=\"og:image\""));
3980 assert!(post_json_ld(&preview).get("image").is_none());
3981 assert!(!preview.contains(&prefix));
3982 }
3983
3984 #[test]
3985 fn external_image_metadata_uses_validated_urls_and_escaped_attributes() {
3986 let fixture = image_fixture(
3987 "https://cdn.example/icon.png",
3988 "https://cdn.example/site.png",
3989 "https://cdn.example/article.png?x=1&y=2",
3990 );
3991 let snapshot =
3992 build_snapshot(&fixture, &projection([entry(&fixture, FIRST_ID, 2_000)])).unwrap();
3993 assert!(
3994 snapshot
3995 .index_page()
3996 .contains("<link rel=\"icon\" href=\"https://cdn.example/icon.png\">")
3997 );
3998 assert!(
3999 snapshot
4000 .index_page()
4001 .contains("<meta property=\"og:image\" content=\"https://cdn.example/site.png\">")
4002 );
4003 let article = snapshot
4004 .post_page(&PostSlug::parse("image-post").unwrap())
4005 .unwrap();
4006 assert!(article.contains(
4007 "<meta property=\"og:image\" content=\"https://cdn.example/article.png?x=1&y=2\">"
4008 ));
4009 assert_eq!(
4010 post_json_ld(&article)["image"],
4011 "https://cdn.example/article.png?x=1&y=2"
4012 );
4013 assert!(snapshot.assets.is_empty());
4014 let preview = render_post_preview(
4015 &fixture.catalog,
4016 embedded_manifest(),
4017 &PostId::parse(FIRST_ID).unwrap(),
4018 "/api/admin/v1/preview-assets/example",
4019 None,
4020 )
4021 .unwrap()
4022 .unwrap();
4023 assert_eq!(
4024 post_json_ld(&preview)["image"],
4025 "https://cdn.example/article.png?x=1&y=2"
4026 );
4027 }
4028
4029 #[test]
4030 fn site_image_changes_invalidate_snapshot_and_preview_approval_bindings() {
4031 let first = image_fixture(
4032 "assets/favicon.png",
4033 "assets/site.png",
4034 "assets/article.png",
4035 );
4036 let changed = image_fixture(
4037 "assets/favicon.png",
4038 "assets/favicon.png",
4039 "assets/article.png",
4040 );
4041 let first_snapshot =
4042 build_snapshot(&first, &projection([entry(&first, FIRST_ID, 2_000)])).unwrap();
4043 let changed_snapshot =
4044 build_snapshot(&changed, &projection([entry(&changed, FIRST_ID, 2_000)])).unwrap();
4045 assert_ne!(first_snapshot.digest, changed_snapshot.digest);
4046 let preview = |fixture: &Fixture| {
4047 render_bound_post_preview(
4048 &fixture.catalog,
4049 embedded_manifest(),
4050 &PostId::parse(FIRST_ID).unwrap(),
4051 None,
4052 "/api/admin/v1/preview-assets/example",
4053 None,
4054 )
4055 .unwrap()
4056 .unwrap()
4057 };
4058 assert_ne!(preview(&first).digest, preview(&changed).digest);
4059 assert_eq!(first.revisions, changed.revisions);
4060 }
4061 #[test]
4062 fn absent_site_image_does_not_substitute_the_favicon() {
4063 let fixture = fixture();
4064 let snapshot = build_snapshot(&fixture, &PublicLedgerProjection::empty()).unwrap();
4065 assert!(snapshot.index_page().contains("<link rel=\"icon\""));
4066 assert!(!snapshot.index_page().contains("property=\"og:image\""));
4067 }
4068}