Skip to main content

webserver_base/templates/
base.rs

1//! Per-server template data: what is true of every page on the site.
2
3use serde::{Deserialize, Serialize};
4
5use super::site_entity::SiteEntity;
6use super::theme::{Fallback, ThemeColor, ThemeScript};
7
8/// Everything [`BaseTemplateData::new`] requires.
9///
10/// A named-field struct rather than sixteen positional arguments: every field
11/// is mandatory either way, but this one cannot be built with two of them
12/// transposed.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct BaseTemplateDataParams {
15    /// The site's name, e.g. `Boggledygook`.
16    pub project: String,
17    /// The default `<meta name="description">`.
18    pub description: String,
19    /// Who wrote it.
20    pub author: String,
21    /// The site's origin, without a trailing slash — `https://www.example.com`.
22    pub base_url: String,
23    /// The Twitter/X handle, without the leading `@`.
24    pub twitter_username: String,
25    /// The default Open Graph / Twitter card image.
26    pub social_image: String,
27    /// The default alt text for that image.
28    pub social_image_alt: String,
29    /// The browser-chrome colour, and the schemes this site supports.
30    pub theme_color: ThemeColor,
31    /// Which theme to stamp when neither the reader nor their OS states a
32    /// preference. Design data, so it lives beside the colours it chooses
33    /// between.
34    pub theme_fallback: Fallback,
35    /// Who or what the site represents, for schema.org.
36    pub site_entity: SiteEntity,
37    /// The `<html lang>` language subtag, e.g. `en`.
38    pub language_code: String,
39    /// The region subtag, e.g. `US`.
40    pub country_code: String,
41    /// Other places this author exists, emitted as `og:see_also`.
42    pub see_also: Vec<String>,
43    /// The first year of the copyright range. The last is computed per render.
44    pub copyright_start: String,
45    /// Stylesheets every page loads.
46    pub style_sheets: Vec<String>,
47    /// Scripts every page loads.
48    pub scripts: Vec<String>,
49}
50
51/// Per-server template data.
52///
53/// Built once at boot and held in the server's state. Everything on it can be
54/// overridden for a single page by [`PageTemplateData`](super::PageTemplateData),
55/// except the fields that identify the site itself.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct BaseTemplateData {
58    project: String,
59    description: String,
60    author: String,
61    base_url: String,
62    twitter_username: String,
63    social_image: String,
64    social_image_alt: String,
65    theme_color: ThemeColor,
66    theme_fallback: Fallback,
67    site_entity: SiteEntity,
68    language_code: String,
69    country_code: String,
70    see_also: Vec<String>,
71    copyright_start: String,
72    style_sheets: Vec<String>,
73    scripts: Vec<String>,
74}
75
76impl BaseTemplateData {
77    /// Builds base data with every field supplied.
78    ///
79    /// Two paths are normalised on the way in. A trailing slash on `base_url`
80    /// is stripped, so joining it with a page URL cannot produce
81    /// `https://example.com//blog`. A *leading* slash on `social_image` is
82    /// stripped, so it keys into the cache-buster map exactly like the
83    /// stylesheet and script paths beside it — without that, the social card is
84    /// the one asset on the site that can never be content-hashed.
85    #[must_use]
86    pub fn new(params: BaseTemplateDataParams) -> Self {
87        Self {
88            project: params.project,
89            description: params.description,
90            author: params.author,
91            base_url: normalize_base_url(&params.base_url),
92            twitter_username: params.twitter_username,
93            social_image: normalize_asset_path(&params.social_image),
94            social_image_alt: params.social_image_alt,
95            theme_color: params.theme_color,
96            theme_fallback: params.theme_fallback,
97            site_entity: params.site_entity,
98            language_code: params.language_code,
99            country_code: params.country_code,
100            see_also: params.see_also,
101            copyright_start: params.copyright_start,
102            style_sheets: params.style_sheets,
103            scripts: params.scripts,
104        }
105    }
106
107    /// Overrides the author.
108    #[must_use]
109    pub fn with_author(mut self, author: impl Into<String>) -> Self {
110        self.author = author.into();
111        self
112    }
113
114    /// Overrides the Twitter/X handle.
115    #[must_use]
116    pub fn with_twitter_username(mut self, twitter_username: impl Into<String>) -> Self {
117        self.twitter_username = twitter_username.into();
118        self
119    }
120
121    /// Overrides who or what the site represents.
122    #[must_use]
123    pub fn with_site_entity(mut self, site_entity: SiteEntity) -> Self {
124        self.site_entity = site_entity;
125        self
126    }
127
128    /// Overrides the language subtag.
129    #[must_use]
130    pub fn with_language_code(mut self, language_code: impl Into<String>) -> Self {
131        self.language_code = language_code.into();
132        self
133    }
134
135    /// Overrides the region subtag.
136    #[must_use]
137    pub fn with_country_code(mut self, country_code: impl Into<String>) -> Self {
138        self.country_code = country_code.into();
139        self
140    }
141
142    /// Replaces the `og:see_also` list wholesale.
143    ///
144    /// For a project that should not carry the author's personal links at all.
145    #[must_use]
146    pub fn replace_see_also<I, S>(mut self, see_also: I) -> Self
147    where
148        I: IntoIterator<Item = S>,
149        S: Into<String>,
150    {
151        self.see_also = see_also.into_iter().map(Into::into).collect();
152        self
153    }
154
155    /// Appends to the `og:see_also` list.
156    ///
157    /// For a project with its own presence alongside the author's — a band's
158    /// `YouTube` channel next to a personal one.
159    #[must_use]
160    pub fn extend_see_also<I, S>(mut self, see_also: I) -> Self
161    where
162        I: IntoIterator<Item = S>,
163        S: Into<String>,
164    {
165        self.see_also.extend(see_also.into_iter().map(Into::into));
166        self
167    }
168
169    /// Appends to the site-wide stylesheet list.
170    #[must_use]
171    pub fn extend_style_sheets<I, S>(mut self, style_sheets: I) -> Self
172    where
173        I: IntoIterator<Item = S>,
174        S: Into<String>,
175    {
176        self.style_sheets
177            .extend(style_sheets.into_iter().map(Into::into));
178        self
179    }
180
181    /// Appends to the site-wide script list.
182    #[must_use]
183    pub fn extend_scripts<I, S>(mut self, scripts: I) -> Self
184    where
185        I: IntoIterator<Item = S>,
186        S: Into<String>,
187    {
188        self.scripts.extend(scripts.into_iter().map(Into::into));
189        self
190    }
191
192    /// The site's name.
193    #[must_use]
194    pub fn project(&self) -> &str {
195        &self.project
196    }
197    /// The default description.
198    #[must_use]
199    pub fn description(&self) -> &str {
200        &self.description
201    }
202    /// The author.
203    #[must_use]
204    pub fn author(&self) -> &str {
205        &self.author
206    }
207    /// The site origin, with no trailing slash.
208    #[must_use]
209    pub fn base_url(&self) -> &str {
210        &self.base_url
211    }
212    /// The Twitter/X handle.
213    #[must_use]
214    pub fn twitter_username(&self) -> &str {
215        &self.twitter_username
216    }
217    /// The default social image.
218    #[must_use]
219    pub fn social_image(&self) -> &str {
220        &self.social_image
221    }
222    /// The default social image alt text.
223    #[must_use]
224    pub fn social_image_alt(&self) -> &str {
225        &self.social_image_alt
226    }
227    /// The browser-chrome colour and supported schemes.
228    #[must_use]
229    pub const fn theme_color(&self) -> &ThemeColor {
230        &self.theme_color
231    }
232    /// Who or what the site represents.
233    #[must_use]
234    pub const fn site_entity(&self) -> &SiteEntity {
235        &self.site_entity
236    }
237    /// The pre-paint theme script this site's design implies.
238    #[must_use]
239    pub const fn theme_script(&self) -> ThemeScript {
240        ThemeScript::new(self.theme_fallback)
241    }
242    /// The language subtag.
243    #[must_use]
244    pub fn language_code(&self) -> &str {
245        &self.language_code
246    }
247    /// The region subtag.
248    #[must_use]
249    pub fn country_code(&self) -> &str {
250        &self.country_code
251    }
252    /// The `og:see_also` list.
253    #[must_use]
254    pub fn see_also(&self) -> &[String] {
255        &self.see_also
256    }
257    /// The first year of the copyright range.
258    #[must_use]
259    pub fn copyright_start(&self) -> &str {
260        &self.copyright_start
261    }
262    /// The site-wide stylesheets.
263    #[must_use]
264    pub fn style_sheets(&self) -> &[String] {
265        &self.style_sheets
266    }
267    /// The site-wide scripts.
268    #[must_use]
269    pub fn scripts(&self) -> &[String] {
270        &self.scripts
271    }
272}
273
274/// Strips any trailing slashes from a site origin.
275fn normalize_base_url(base_url: &str) -> String {
276    base_url.trim().trim_end_matches('/').to_string()
277}
278
279/// Strips a leading slash, so an asset path matches its cache-buster key.
280fn normalize_asset_path(path: &str) -> String {
281    path.trim().trim_start_matches('/').to_string()
282}
283
284// ─────────────────────────────────────────────────────────────────────────────
285// preset
286// ─────────────────────────────────────────────────────────────────────────────
287
288/// Todd Everett Griffin's other web presences, emitted as `og:see_also`.
289#[cfg(feature = "preset")]
290pub const GODDTRIFFIN_SEE_ALSO: [&str; 8] = [
291    "https://www.toddgriffin.me/",
292    "https://x.com/goddtriffin",
293    "https://github.com/goddtriffin",
294    "https://www.instagram.com/goddtriffin/",
295    "https://www.youtube.com/@goddtriffin",
296    "https://www.facebook.com/goddtriffin/",
297    "https://stackoverflow.com/users/11767294/goddtriffin",
298    "https://www.reddit.com/user/goddtriffin",
299];
300
301/// Everything [`BaseTemplateData::goddtriffin`] cannot know for you.
302///
303/// Absent on purpose: the fields the preset fills in — author, language,
304/// region, handle, `site_entity` and `see_also` — and `social_image_alt`, which
305/// is derived as `"{project}: {description}"`. Every one of them can still be
306/// changed on the returned value with a `with_*`, `replace_*` or `extend_*`
307/// method.
308#[cfg(feature = "preset")]
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct GoddtriffinParams {
311    /// The site's name.
312    pub project: String,
313    /// The default description.
314    pub description: String,
315    /// The site's origin, without a trailing slash.
316    pub base_url: String,
317    /// The default social card image.
318    pub social_image: String,
319    /// The browser-chrome colour, and the schemes this site supports.
320    pub theme_color: ThemeColor,
321    /// Which theme to stamp when nothing states a preference.
322    pub theme_fallback: Fallback,
323    /// The first year of the copyright range.
324    pub copyright_start: String,
325    /// Stylesheets every page loads.
326    pub style_sheets: Vec<String>,
327    /// Scripts every page loads.
328    pub scripts: Vec<String>,
329}
330
331#[cfg(feature = "preset")]
332impl BaseTemplateData {
333    /// Base data pre-filled with Todd Everett Griffin's defaults.
334    ///
335    /// Fills in author, language, region, Twitter/X handle,
336    /// [`GODDTRIFFIN_SEE_ALSO`] and a `Person` site entity, and derives
337    /// `social_image_alt` as `"{project}: {description}"`. Everything else is
338    /// required, because it differs per project.
339    ///
340    /// A project that is not *him* — a band, a business — should call
341    /// [`with_site_entity`](Self::with_site_entity) with an
342    /// [`Organization`](SiteEntity::organization), because the entity node is a
343    /// factual claim search engines reconcile against.
344    ///
345    /// To change what the preset filled in, call
346    /// [`replace_see_also`](Self::replace_see_also),
347    /// [`extend_see_also`](Self::extend_see_also), or one of the `with_*`
348    /// methods on the returned value.
349    #[must_use]
350    pub fn goddtriffin(params: GoddtriffinParams) -> Self {
351        let social_image_alt: String = format!("{}: {}", params.project, params.description);
352
353        Self::new(BaseTemplateDataParams {
354            project: params.project,
355            description: params.description,
356            author: String::from("Todd Everett Griffin"),
357            base_url: params.base_url,
358            twitter_username: String::from("goddtriffin"),
359            social_image: params.social_image,
360            social_image_alt,
361            theme_color: params.theme_color,
362            theme_fallback: params.theme_fallback,
363            site_entity: SiteEntity::person("Todd Everett Griffin"),
364            language_code: String::from("en"),
365            country_code: String::from("US"),
366            see_also: GODDTRIFFIN_SEE_ALSO
367                .iter()
368                .map(|url| (*url).to_string())
369                .collect(),
370            copyright_start: params.copyright_start,
371            style_sheets: params.style_sheets,
372            scripts: params.scripts,
373        })
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::{BaseTemplateData, BaseTemplateDataParams, Fallback, SiteEntity, ThemeColor};
380
381    fn params() -> BaseTemplateDataParams {
382        BaseTemplateDataParams {
383            project: String::from("Test Project"),
384            description: String::from("A test."),
385            author: String::from("Someone"),
386            base_url: String::from("https://www.example.com"),
387            twitter_username: String::from("someone"),
388            social_image: String::from("static/social.webp"),
389            social_image_alt: String::from("A test image."),
390            theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
391            theme_fallback: Fallback::Dark,
392            site_entity: SiteEntity::person("Someone"),
393            language_code: String::from("en"),
394            country_code: String::from("US"),
395            see_also: vec![String::from("https://www.example.com/")],
396            copyright_start: String::from("1998"),
397            style_sheets: vec![String::from("static/stylesheet/main.css")],
398            scripts: vec![String::from("static/script/main.js")],
399        }
400    }
401
402    #[test]
403    fn a_trailing_slash_is_stripped_so_urls_never_double_up() {
404        let mut with_slash: BaseTemplateDataParams = params();
405        with_slash.base_url = String::from("https://www.example.com/");
406
407        let expected: String = String::from("https://www.example.com");
408        let actual: String = BaseTemplateData::new(with_slash).base_url().to_string();
409        assert_eq!(expected, actual);
410    }
411
412    #[test]
413    fn a_leading_slash_on_the_social_image_is_stripped_so_it_can_be_cache_busted() {
414        let mut with_slash: BaseTemplateDataParams = params();
415        with_slash.social_image = String::from("/static/image/social/card.webp");
416
417        // The cache-buster map is keyed without a leading slash, exactly like
418        // `style_sheets` and `scripts`; a mismatch here is why the social card
419        // was historically the one asset that never got hashed.
420        let expected: String = String::from("static/image/social/card.webp");
421        let actual: String = BaseTemplateData::new(with_slash).social_image().to_string();
422        assert_eq!(expected, actual);
423    }
424
425    #[test]
426    fn every_field_survives_construction() {
427        let expected: BaseTemplateDataParams = params();
428        let actual: BaseTemplateData = BaseTemplateData::new(params());
429
430        assert_eq!(expected.project, actual.project());
431        assert_eq!(expected.description, actual.description());
432        assert_eq!(expected.author, actual.author());
433        assert_eq!(expected.base_url, actual.base_url());
434        assert_eq!(expected.twitter_username, actual.twitter_username());
435        assert_eq!(expected.social_image, actual.social_image());
436        assert_eq!(expected.social_image_alt, actual.social_image_alt());
437        assert_eq!(&expected.theme_color, actual.theme_color());
438        assert_eq!(expected.theme_fallback, actual.theme_fallback);
439        assert_eq!(&expected.site_entity, actual.site_entity());
440        assert_eq!(expected.language_code, actual.language_code());
441        assert_eq!(expected.country_code, actual.country_code());
442        assert_eq!(expected.see_also, actual.see_also());
443        assert_eq!(expected.copyright_start, actual.copyright_start());
444        assert_eq!(expected.style_sheets, actual.style_sheets());
445        assert_eq!(expected.scripts, actual.scripts());
446    }
447
448    #[test]
449    fn extend_appends_without_discarding() {
450        let base: BaseTemplateData = BaseTemplateData::new(params())
451            .extend_see_also(["https://www.youtube.com/@tripleentendreband"])
452            .extend_scripts(["static/script/home.js"]);
453
454        let expected_see_also: Vec<String> = vec![
455            String::from("https://www.example.com/"),
456            String::from("https://www.youtube.com/@tripleentendreband"),
457        ];
458        let actual_see_also: Vec<String> = base.see_also().to_vec();
459        assert_eq!(expected_see_also, actual_see_also);
460
461        let expected_scripts: Vec<String> = vec![
462            String::from("static/script/main.js"),
463            String::from("static/script/home.js"),
464        ];
465        let actual_scripts: Vec<String> = base.scripts().to_vec();
466        assert_eq!(expected_scripts, actual_scripts);
467    }
468
469    #[cfg(feature = "preset")]
470    #[test]
471    fn the_preset_fills_identity_and_leaves_the_rest_required() {
472        use super::{GODDTRIFFIN_SEE_ALSO, GoddtriffinParams};
473
474        let base: BaseTemplateData = BaseTemplateData::goddtriffin(GoddtriffinParams {
475            project: String::from("Boggledygook"),
476            description: String::from("Boggle, exhaustively."),
477            base_url: String::from("https://www.boggledygook.com"),
478            social_image: String::from("/og-image.png"),
479            theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
480            theme_fallback: Fallback::Dark,
481            copyright_start: String::from("2025"),
482            style_sheets: vec![],
483            scripts: vec![],
484        });
485
486        let expected_author: String = String::from("Todd Everett Griffin");
487        let actual_author: String = base.author().to_string();
488        assert_eq!(expected_author, actual_author);
489
490        let expected_handle: String = String::from("goddtriffin");
491        let actual_handle: String = base.twitter_username().to_string();
492        assert_eq!(expected_handle, actual_handle);
493
494        let expected_entity: SiteEntity = SiteEntity::person("Todd Everett Griffin");
495        assert_eq!(&expected_entity, base.site_entity());
496
497        let expected_see_also: Vec<String> = GODDTRIFFIN_SEE_ALSO
498            .iter()
499            .map(|url| (*url).to_string())
500            .collect();
501        let actual_see_also: Vec<String> = base.see_also().to_vec();
502        assert_eq!(expected_see_also, actual_see_also);
503    }
504
505    #[cfg(feature = "preset")]
506    #[test]
507    fn the_preset_see_also_leads_with_the_personal_site_then_x_then_github() {
508        use super::GODDTRIFFIN_SEE_ALSO;
509
510        let expected: [&str; 3] = [
511            "https://www.toddgriffin.me/",
512            "https://x.com/goddtriffin",
513            "https://github.com/goddtriffin",
514        ];
515        let actual: [&str; 3] = [
516            GODDTRIFFIN_SEE_ALSO[0],
517            GODDTRIFFIN_SEE_ALSO[1],
518            GODDTRIFFIN_SEE_ALSO[2],
519        ];
520        assert_eq!(expected, actual);
521
522        assert!(
523            !GODDTRIFFIN_SEE_ALSO
524                .iter()
525                .any(|url| url.contains("twitter.com")),
526            "twitter.com was replaced by x.com"
527        );
528    }
529
530    #[cfg(feature = "preset")]
531    #[test]
532    fn a_preset_field_can_still_be_overridden() {
533        use super::GoddtriffinParams;
534
535        let base: BaseTemplateData = BaseTemplateData::goddtriffin(GoddtriffinParams {
536            project: String::from("Triple Entendre"),
537            description: String::from("A band."),
538            base_url: String::from("https://www.tripleentendreband.com"),
539            social_image: String::from("/static/social.webp"),
540            theme_color: ThemeColor::dark("#000000"),
541            theme_fallback: Fallback::Dark,
542            copyright_start: String::from("2019"),
543            style_sheets: vec![],
544            scripts: vec![],
545        })
546        .with_twitter_username("tripleentendre")
547        .extend_see_also(["https://www.youtube.com/@tripleentendreband"]);
548
549        let expected_handle: String = String::from("tripleentendre");
550        let actual_handle: String = base.twitter_username().to_string();
551        assert_eq!(expected_handle, actual_handle);
552
553        let expected_len: usize = 9;
554        let actual_len: usize = base.see_also().len();
555        assert_eq!(expected_len, actual_len);
556    }
557
558    #[cfg(feature = "preset")]
559    #[test]
560    fn the_preset_derives_social_image_alt_from_project_and_description() {
561        use super::GoddtriffinParams;
562
563        let base: BaseTemplateData = BaseTemplateData::goddtriffin(GoddtriffinParams {
564            project: String::from("Scannable Codes"),
565            description: String::from("Every scannable code, explained."),
566            base_url: String::from("https://www.scannablecodes.com"),
567            social_image: String::from("/static/social.webp"),
568            theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
569            theme_fallback: Fallback::Dark,
570            copyright_start: String::from("1998"),
571            style_sheets: vec![],
572            scripts: vec![],
573        });
574
575        let expected: String = String::from("Scannable Codes: Every scannable code, explained.");
576        let actual: String = base.social_image_alt().to_string();
577        assert_eq!(expected, actual);
578    }
579
580    #[test]
581    fn replace_see_also_discards_whatever_was_there() {
582        let base: BaseTemplateData = BaseTemplateData::new(params())
583            .replace_see_also(["https://www.youtube.com/@tripleentendreband"]);
584
585        let expected: Vec<String> =
586            vec![String::from("https://www.youtube.com/@tripleentendreband")];
587        let actual: Vec<String> = base.see_also().to_vec();
588        assert_eq!(expected, actual);
589    }
590
591    #[test]
592    fn replace_then_extend_composes_in_that_order() {
593        let base: BaseTemplateData = BaseTemplateData::new(params())
594            .replace_see_also(["https://a.example.com"])
595            .extend_see_also(["https://b.example.com"]);
596
597        let expected: Vec<String> = vec![
598            String::from("https://a.example.com"),
599            String::from("https://b.example.com"),
600        ];
601        let actual: Vec<String> = base.see_also().to_vec();
602        assert_eq!(expected, actual);
603    }
604}