Skip to main content

webserver_base/templates/
page.rs

1//! Per-render template data: what this page is, this time.
2
3use chrono::{DateTime, Utc};
4use serde::Serialize;
5use serde_json::Value;
6
7use super::robots;
8
9/// Article metadata, for a page that is a piece of writing rather than a part
10/// of the site.
11///
12/// Supplying this is what makes a page an `article`: `og:type` is derived from
13/// its presence, so a page cannot claim to be an article without dates, nor
14/// carry dates that never reach the markup.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct Article {
17    /// When the piece was first published.
18    pub published_time: DateTime<Utc>,
19    /// When it was last meaningfully revised, if ever.
20    pub modified_time: Option<DateTime<Utc>>,
21    /// The section it belongs to, e.g. `Engineering`.
22    pub section: Option<String>,
23    /// Free-form tags.
24    pub tags: Vec<String>,
25}
26
27impl Article {
28    /// An article published at `published_time`, with nothing else set.
29    #[must_use]
30    pub const fn new(published_time: DateTime<Utc>) -> Self {
31        Self {
32            published_time,
33            modified_time: None,
34            section: None,
35            tags: Vec::new(),
36        }
37    }
38
39    /// Records a revision date.
40    #[must_use]
41    pub const fn with_modified_time(mut self, modified_time: DateTime<Utc>) -> Self {
42        self.modified_time = Some(modified_time);
43        self
44    }
45
46    /// Sets the section.
47    #[must_use]
48    pub fn with_section(mut self, section: impl Into<String>) -> Self {
49        self.section = Some(section.into());
50        self
51    }
52
53    /// Adds tags.
54    #[must_use]
55    pub fn extend_tags<I, S>(mut self, tags: I) -> Self
56    where
57        I: IntoIterator<Item = S>,
58        S: Into<String>,
59    {
60        self.tags.extend(tags.into_iter().map(Into::into));
61        self
62    }
63}
64
65/// Per-render template data.
66///
67/// Non-generic on purpose: the data a page displays is passed alongside this to
68/// the render call rather than stored on it, so every builder method below is
69/// type-preserving.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct PageTemplateData {
72    template: String,
73    page_name: String,
74    page_url: String,
75
76    description: Option<String>,
77    social_image: Option<String>,
78    social_image_alt: Option<String>,
79    robots: Option<String>,
80    jsonld: Option<Value>,
81    article: Option<Article>,
82
83    style_sheets: Option<Vec<String>>,
84    scripts: Option<Vec<String>>,
85    extra_style_sheets: Vec<String>,
86    extra_scripts: Vec<String>,
87}
88
89impl PageTemplateData {
90    /// Names a page.
91    ///
92    /// - `template` is the registered Handlebars name, e.g. `home`.
93    /// - `page_name` is the human name, e.g. `Blog`. It becomes the `<title>`
94    ///   via the computed `display_name`, which is the *only* way to influence
95    ///   the title.
96    /// - `page_url` is this page's path from the site root, with a leading
97    ///   slash, e.g. `/blog`. It becomes the canonical URL.
98    #[must_use]
99    pub fn new(
100        template: impl Into<String>,
101        page_name: impl Into<String>,
102        page_url: impl Into<String>,
103    ) -> Self {
104        Self {
105            template: template.into(),
106            page_name: page_name.into(),
107            page_url: normalize_page_url(&page_url.into()),
108            description: None,
109            social_image: None,
110            social_image_alt: None,
111            robots: None,
112            jsonld: None,
113            article: None,
114            style_sheets: None,
115            scripts: None,
116            extra_style_sheets: Vec::new(),
117            extra_scripts: Vec::new(),
118        }
119    }
120
121    /// Overrides the base description for this page.
122    #[must_use]
123    pub fn with_description(mut self, description: impl Into<String>) -> Self {
124        self.description = Some(description.into());
125        self
126    }
127
128    /// Overrides the base social card image for this page.
129    #[must_use]
130    pub fn with_social_image(mut self, social_image: impl Into<String>) -> Self {
131        self.social_image = Some(social_image.into());
132        self
133    }
134
135    /// Overrides the base social card alt text for this page.
136    #[must_use]
137    pub fn with_social_image_alt(mut self, social_image_alt: impl Into<String>) -> Self {
138        self.social_image_alt = Some(social_image_alt.into());
139        self
140    }
141
142    /// Overrides [`robots::DEFAULT`] for this page. See [`robots`] for the
143    /// common directives.
144    #[must_use]
145    pub fn with_robots(mut self, robots: impl Into<String>) -> Self {
146        self.robots = Some(robots.into());
147        self
148    }
149
150    /// Attaches a schema.org JSON-LD document.
151    ///
152    /// Takes a [`Value`] rather than a pre-escaped string so the escaping cannot
153    /// be skipped — a `</script>` inside a string value would otherwise break
154    /// out of the block. `@context` is filled in when absent.
155    #[must_use]
156    pub fn with_jsonld(mut self, jsonld: Value) -> Self {
157        self.jsonld = Some(jsonld);
158        self
159    }
160
161    /// Marks this page as an article and attaches its metadata.
162    ///
163    /// This is the only way to set `og:type` to `article`, so the type and the
164    /// `article:*` tags can never disagree.
165    #[must_use]
166    pub fn with_article(mut self, article: Article) -> Self {
167        self.article = Some(article);
168        self
169    }
170
171    /// Replaces the base stylesheet list wholesale for this page.
172    #[must_use]
173    pub fn replace_style_sheets<I, S>(mut self, style_sheets: I) -> Self
174    where
175        I: IntoIterator<Item = S>,
176        S: Into<String>,
177    {
178        self.style_sheets = Some(style_sheets.into_iter().map(Into::into).collect());
179        self
180    }
181
182    /// Appends to whatever stylesheet list this page ends up with.
183    #[must_use]
184    pub fn extend_style_sheets<I, S>(mut self, style_sheets: I) -> Self
185    where
186        I: IntoIterator<Item = S>,
187        S: Into<String>,
188    {
189        self.extra_style_sheets
190            .extend(style_sheets.into_iter().map(Into::into));
191        self
192    }
193
194    /// Replaces the base script list wholesale for this page.
195    #[must_use]
196    pub fn replace_scripts<I, S>(mut self, scripts: I) -> Self
197    where
198        I: IntoIterator<Item = S>,
199        S: Into<String>,
200    {
201        self.scripts = Some(scripts.into_iter().map(Into::into).collect());
202        self
203    }
204
205    /// Appends to whatever script list this page ends up with.
206    #[must_use]
207    pub fn extend_scripts<I, S>(mut self, scripts: I) -> Self
208    where
209        I: IntoIterator<Item = S>,
210        S: Into<String>,
211    {
212        self.extra_scripts
213            .extend(scripts.into_iter().map(Into::into));
214        self
215    }
216
217    /// The registered Handlebars template name.
218    #[must_use]
219    pub fn template(&self) -> &str {
220        &self.template
221    }
222    /// The human page name.
223    #[must_use]
224    pub fn page_name(&self) -> &str {
225        &self.page_name
226    }
227    /// This page's path from the site root.
228    #[must_use]
229    pub fn page_url(&self) -> &str {
230        &self.page_url
231    }
232    /// This page's description override, if any.
233    #[must_use]
234    pub fn description(&self) -> Option<&str> {
235        self.description.as_deref()
236    }
237    /// This page's social image override, if any.
238    #[must_use]
239    pub fn social_image(&self) -> Option<&str> {
240        self.social_image.as_deref()
241    }
242    /// This page's social image alt override, if any.
243    #[must_use]
244    pub fn social_image_alt(&self) -> Option<&str> {
245        self.social_image_alt.as_deref()
246    }
247    /// This page's robots directive, defaulting to [`robots::DEFAULT`].
248    #[must_use]
249    pub fn robots(&self) -> &str {
250        self.robots.as_deref().unwrap_or(robots::DEFAULT)
251    }
252    /// This page's JSON-LD document, if any.
253    #[must_use]
254    pub const fn jsonld(&self) -> Option<&Value> {
255        self.jsonld.as_ref()
256    }
257    /// This page's article metadata, if it is one.
258    #[must_use]
259    pub const fn article(&self) -> Option<&Article> {
260        self.article.as_ref()
261    }
262    /// The Open Graph object type, derived from whether this page is an article.
263    #[must_use]
264    pub const fn og_type(&self) -> &'static str {
265        if self.article.is_some() {
266            "article"
267        } else {
268            "website"
269        }
270    }
271
272    /// Every asset path this page declares for itself.
273    ///
274    /// Boot-time validation walks these so a typo in a page-specific stylesheet
275    /// fails the deploy rather than silently 404ing for a visitor.
276    #[must_use]
277    pub fn declared_assets(&self) -> Vec<String> {
278        let mut declared: Vec<String> = Vec::new();
279        for list in [self.style_sheets.as_deref(), self.scripts.as_deref()]
280            .into_iter()
281            .flatten()
282        {
283            declared.extend(list.iter().cloned());
284        }
285        declared.extend(self.extra_style_sheets.iter().cloned());
286        declared.extend(self.extra_scripts.iter().cloned());
287        if let Some(social_image) = self.social_image.as_deref() {
288            declared.push(social_image.to_string());
289        }
290        declared
291    }
292
293    /// Resolves the final stylesheet list: base, optionally replaced, then
294    /// extended.
295    #[must_use]
296    pub fn resolve_style_sheets<'a>(&'a self, base: &'a [String]) -> Vec<&'a str> {
297        resolve(self.style_sheets.as_deref(), base, &self.extra_style_sheets)
298    }
299
300    /// Resolves the final script list: base, optionally replaced, then
301    /// extended.
302    #[must_use]
303    pub fn resolve_scripts<'a>(&'a self, base: &'a [String]) -> Vec<&'a str> {
304        resolve(self.scripts.as_deref(), base, &self.extra_scripts)
305    }
306}
307
308/// base → wholesale replacement (if any) → additions.
309fn resolve<'a>(
310    replacement: Option<&'a [String]>,
311    base: &'a [String],
312    additions: &'a [String],
313) -> Vec<&'a str> {
314    replacement
315        .unwrap_or(base)
316        .iter()
317        .chain(additions.iter())
318        .map(String::as_str)
319        .collect()
320}
321
322/// Exactly one leading slash and no trailing one, so `base_url + page_url` is
323/// always well-formed. The site root stays a bare `/`.
324fn normalize_page_url(page_url: &str) -> String {
325    let trimmed: &str = page_url.trim();
326    if trimmed.is_empty() || trimmed == "/" {
327        return String::from("/");
328    }
329    let without_trailing: &str = trimmed.trim_end_matches('/');
330    if without_trailing.starts_with('/') {
331        without_trailing.to_string()
332    } else {
333        format!("/{without_trailing}")
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use chrono::{DateTime, TimeZone, Utc};
340    use serde_json::json;
341
342    use super::{Article, PageTemplateData, normalize_page_url};
343    use crate::templates::robots;
344
345    fn base_sheets() -> Vec<String> {
346        vec![
347            String::from("static/stylesheet/main.css"),
348            String::from("static/stylesheet/theme.css"),
349        ]
350    }
351
352    #[test]
353    fn a_page_defaults_to_maximum_indexability() {
354        let page: PageTemplateData = PageTemplateData::new("home", "Home", "/");
355
356        let expected: String = String::from(robots::DEFAULT);
357        let actual: String = page.robots().to_string();
358        assert_eq!(expected, actual);
359    }
360
361    #[test]
362    fn a_page_can_opt_out_of_indexing() {
363        let page: PageTemplateData =
364            PageTemplateData::new("404", "404", "/404").with_robots(robots::NOINDEX_FOLLOW);
365
366        let expected: String = String::from("noindex, follow");
367        let actual: String = page.robots().to_string();
368        assert_eq!(expected, actual);
369    }
370
371    #[test]
372    fn page_urls_are_normalized_to_one_leading_and_no_trailing_slash() {
373        let cases: [(&str, &str); 6] = [
374            ("/", "/"),
375            ("", "/"),
376            ("blog", "/blog"),
377            ("/blog", "/blog"),
378            ("/blog/", "/blog"),
379            ("  /blog/tips/  ", "/blog/tips"),
380        ];
381        for (input, expected) in cases {
382            let actual: String = normalize_page_url(input);
383            assert_eq!(expected, actual, "input was {input:?}");
384        }
385    }
386
387    #[test]
388    fn assets_default_to_the_base_list() {
389        let page: PageTemplateData = PageTemplateData::new("home", "Home", "/");
390
391        let base: Vec<String> = base_sheets();
392        let expected: Vec<&str> = vec!["static/stylesheet/main.css", "static/stylesheet/theme.css"];
393        let actual: Vec<&str> = page.resolve_style_sheets(&base);
394        assert_eq!(expected, actual);
395    }
396
397    #[test]
398    fn extend_appends_to_the_base_list() {
399        let page: PageTemplateData = PageTemplateData::new("home", "Home", "/")
400            .extend_style_sheets(["static/stylesheet/home.css"]);
401
402        let expected: Vec<&str> = vec![
403            "static/stylesheet/main.css",
404            "static/stylesheet/theme.css",
405            "static/stylesheet/home.css",
406        ];
407        let base: Vec<String> = base_sheets();
408        let actual: Vec<&str> = page.resolve_style_sheets(&base);
409        assert_eq!(expected, actual);
410    }
411
412    #[test]
413    fn replace_discards_the_base_list_then_extend_still_appends() {
414        let page: PageTemplateData = PageTemplateData::new("play", "Play", "/play")
415            .replace_style_sheets(["static/stylesheet/play.css"])
416            .extend_style_sheets(["static/stylesheet/tiles.css"]);
417
418        let base: Vec<String> = base_sheets();
419        let expected: Vec<&str> = vec!["static/stylesheet/play.css", "static/stylesheet/tiles.css"];
420        let actual: Vec<&str> = page.resolve_style_sheets(&base);
421        assert_eq!(expected, actual);
422    }
423
424    #[test]
425    fn overrides_are_absent_until_set() {
426        let bare: PageTemplateData = PageTemplateData::new("home", "Home", "/");
427
428        assert_eq!(None, bare.description());
429        assert_eq!(None, bare.social_image());
430        assert_eq!(None, bare.social_image_alt());
431        assert_eq!(None, bare.jsonld());
432        assert_eq!(None, bare.article());
433
434        let overridden: PageTemplateData = bare
435            .with_description("A specific page.")
436            .with_social_image("/static/image/social/blog.webp")
437            .with_social_image_alt("The blog card")
438            .with_jsonld(json!({ "@type": "BlogPosting" }));
439
440        assert_eq!(Some("A specific page."), overridden.description());
441        assert_eq!(
442            Some("/static/image/social/blog.webp"),
443            overridden.social_image()
444        );
445        assert_eq!(Some("The blog card"), overridden.social_image_alt());
446        assert!(overridden.jsonld().is_some());
447    }
448
449    #[test]
450    fn a_page_is_a_website_until_it_is_given_article_metadata() {
451        let page: PageTemplateData = PageTemplateData::new("home", "Home", "/");
452
453        let expected: &str = "website";
454        let actual: &str = page.og_type();
455        assert_eq!(expected, actual);
456    }
457
458    #[test]
459    fn attaching_article_metadata_is_what_makes_the_og_type_an_article() {
460        let published: DateTime<Utc> = Utc.with_ymd_and_hms(2026, 1, 15, 0, 0, 0).unwrap();
461        let page: PageTemplateData = PageTemplateData::new("blog-post", "A Post", "/blog/a-post")
462            .with_article(
463                Article::new(published)
464                    .with_section("Engineering")
465                    .extend_tags(["rust", "axum"]),
466            );
467
468        let expected: &str = "article";
469        let actual: &str = page.og_type();
470        assert_eq!(expected, actual);
471
472        let article: &Article = page.article().expect("set above");
473        assert_eq!(published, article.published_time);
474        assert_eq!(None, article.modified_time);
475        assert_eq!(Some(String::from("Engineering")), article.section);
476        assert_eq!(
477            vec![String::from("rust"), String::from("axum")],
478            article.tags
479        );
480    }
481}