webserver_base/templates/frontend.rs
1//! Per-server frontend data, computed once at boot.
2//!
3//! Everything here is true of every page but owned by neither
4//! [`BaseTemplateData`](super::BaseTemplateData) (which is what the *author*
5//! declares) nor [`PageTemplateData`](super::PageTemplateData) (which is what
6//! *this render* is): it is what the server worked out for itself at startup.
7
8use serde::Serialize;
9
10/// Where the first-party Plausible proxy lives on this origin.
11///
12/// Both paths are derived from the project name rather than configured. Two
13/// reasons: Plausible's own guidance is to avoid their default paths because
14/// blocklists target them, and a single library-wide constant shared across
15/// every site would be one filter rule away from breaking all of them at once.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub struct AnalyticsPaths {
18 /// The proxied script, e.g. `/script/boggledygook-a3f2c1d8.js`.
19 pub script_path: String,
20 /// The proxied event endpoint, e.g. `/api/v1/boggledygook-a3f2c1d8`.
21 pub event_path: String,
22}
23
24/// Browser-side error monitoring.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26pub struct SentryBrowser {
27 /// The proxied Sentry loader script.
28 pub script_path: String,
29 /// Where the browser SDK posts envelopes, relayed verbatim to Sentry.
30 pub tunnel_path: String,
31 /// The Sentry `environment` tag.
32 pub environment: String,
33}
34
35/// What the social card image actually is.
36///
37/// Read from the image's header bytes at boot rather than declared, because a
38/// hand-typed dimension can drift from the file and a wrong `og:image:width`
39/// makes platforms render the card at the wrong aspect ratio — worse than
40/// omitting it.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
42pub struct SocialImageMetadata {
43 pub width: Option<u32>,
44 pub height: Option<u32>,
45 pub mime_type: Option<&'static str>,
46}
47
48/// The smallest image X/Twitter will render as a `summary_large_image` card.
49pub const MINIMUM_SOCIAL_IMAGE_WIDTH: u32 = 600;
50/// The matching minimum height.
51pub const MINIMUM_SOCIAL_IMAGE_HEIGHT: u32 = 314;
52
53impl SocialImageMetadata {
54 /// Whether this image is large enough for the wide card the layout
55 /// declares. A smaller one silently degrades to a thumbnail.
56 #[must_use]
57 pub const fn is_large_enough(&self) -> bool {
58 match (self.width, self.height) {
59 (Some(width), Some(height)) => {
60 width >= MINIMUM_SOCIAL_IMAGE_WIDTH && height >= MINIMUM_SOCIAL_IMAGE_HEIGHT
61 }
62 // Unknown dimensions are not a failure; nothing is claimed.
63 _ => true,
64 }
65 }
66}
67
68/// The autodiscovery links a reader uses to find the feed.
69///
70/// Emitted on every page, not only on the one the feed mirrors: a reader handed
71/// any URL on the site should still find the stream. Ordered RSS, Atom, JSON in
72/// the layout, because a reader that takes the first `rel="alternate"` rather
73/// than offering a choice is far more likely to be RSS-only than Atom-only.
74///
75/// Plain strings rather than a dependency on the `feed` kit: the layout needs
76/// three URLs and a name, and nothing here should force a templates-only
77/// consumer to compile a feed serializer.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct FeedLinks {
80 /// The feed's name, shown by a reader that presents a picker.
81 pub title: String,
82 /// Absolute URL of the RSS 2.0 document.
83 pub rss: String,
84 /// Absolute URL of the Atom 1.0 document.
85 pub atom: String,
86 /// Absolute URL of the JSON Feed document.
87 pub json: String,
88}
89
90/// Everything a frontend computes once and reuses on every render.
91#[derive(Debug, Clone, Serialize)]
92pub struct FrontendRuntime {
93 /// The inline pre-paint theme script.
94 pub theme_script: String,
95 /// The social image's real dimensions and MIME type.
96 pub social_image: SocialImageMetadata,
97 /// Whether an SVG icon exists to link. A project whose art is a photograph
98 /// authors a PNG instead, and the layout must not link a file that is not
99 /// there.
100 pub has_svg_icon: bool,
101 /// Analytics proxy paths.
102 pub analytics: AnalyticsPaths,
103 /// Browser error monitoring.
104 pub sentry_browser: SentryBrowser,
105 /// Feed autodiscovery, when the site declared a feed.
106 pub feed: Option<FeedLinks>,
107}
108
109#[cfg(test)]
110mod tests {
111 use super::{MINIMUM_SOCIAL_IMAGE_HEIGHT, MINIMUM_SOCIAL_IMAGE_WIDTH, SocialImageMetadata};
112
113 #[test]
114 fn an_image_at_the_floor_is_large_enough() {
115 let image: SocialImageMetadata = SocialImageMetadata {
116 width: Some(MINIMUM_SOCIAL_IMAGE_WIDTH),
117 height: Some(MINIMUM_SOCIAL_IMAGE_HEIGHT),
118 mime_type: Some("image/webp"),
119 };
120
121 let expected: bool = true;
122 let actual: bool = image.is_large_enough();
123 assert_eq!(expected, actual);
124 }
125
126 #[test]
127 fn an_image_below_the_floor_would_degrade_to_a_thumbnail() {
128 let image: SocialImageMetadata = SocialImageMetadata {
129 width: Some(400),
130 height: Some(210),
131 mime_type: Some("image/webp"),
132 };
133
134 let expected: bool = false;
135 let actual: bool = image.is_large_enough();
136 assert_eq!(expected, actual);
137 }
138
139 #[test]
140 fn unknown_dimensions_are_not_treated_as_a_failure() {
141 let expected: bool = true;
142 let actual: bool = SocialImageMetadata::default().is_large_enough();
143 assert_eq!(expected, actual);
144 }
145}