Skip to main content

maincopy_server/render/
site.rs

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;
45// Index, archive, feed, robots, sitemap, and the two rendered fallback pages.
46const 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
117/// An opaque, candidate-bound site rendering capability.
118///
119/// It owns the exact catalog and binds the publication, site-asset policy,
120/// frontend bundle, and public ledger used to produce its shell plan.
121pub 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
222/// One rendered private document and the exact presentation binding it exposes for approval.
223pub(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
230/// Renders and binds one current candidate without including its future activation time.
231pub(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
254/// Reproduces the approval binding for one exact retained post revision.
255pub(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    /// Consumes the exact inputs selected and validated when this shell was rendered.
691    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            &not_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    // One fixed snapshot digest and the map's unique logical paths make this
980    // transformation injective, so collection cannot replace an earlier asset.
981    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
1167/// Complete immutable request-facing state for one canonical publication.
1168pub 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/// Cloneable read-only access to the currently active immutable snapshot.
1250#[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 &current.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/// Local URLs use a stable marker while hashing to avoid a snapshot self-reference.
1477#[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        // Private local assets have no canonical public URL before release.
1530        // Their exact reference remains bound into the preview identity.
1531        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
2127/// The sole trusted-HTML sink for rendered Markdown in the Maud shell.
2128fn 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![public](assets/public.png)\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![private](assets/private.png)\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![draft](assets/draft.png)\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 &lt;unsafe&gt; &amp; 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 &lt;unsafe&gt; &amp; 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        // Only the authenticated favicon projection and unreleased image metadata differ.
2576        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 &lt;Writer&gt; &amp; 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 &lt;script&gt;alert(1)&lt;/script&gt;"));
2780        assert!(page.contains("&lt;script&gt;alert(\'body\')&lt;/script&gt;"));
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 &lt;unsafe&gt; &amp; title",
2860            "A &lt;careful&gt; 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 &lt;unsafe&gt; &amp; 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 &lt;unsafe&gt; &amp; 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 &lt;script&gt;alert(1)&lt;/script&gt;",
2886            "Description &lt;unsafe&gt; &amp; 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![public](assets/public.png)\n",
3015            b"retained public bytes",
3016        );
3017        let mut current = fixture_with_first(
3018            "# Current unpublished body\n![public](assets/public.png)\n",
3019            b"current public bytes",
3020        );
3021        let retained_first = entry(&prior, FIRST_ID, 1_000);
3022        let current_second = entry(&current, 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(&current, &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(&current, 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(&current, &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(&current.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(&current, 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(&current, &current_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 &lt;script&gt;").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 &lt;script&gt;").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 &lt;script&gt;alert(1)&lt;/script&gt;"));
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&amp;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}