Skip to main content

webserver_base/templates/
render.rs

1//! The struct Handlebars actually sees.
2
3use std::collections::BTreeMap;
4
5use chrono::{Datelike, Utc};
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::Environment;
10
11use super::base::BaseTemplateData;
12use super::error::TemplateError;
13use super::frontend::FrontendRuntime;
14use super::page::{Article, PageTemplateData};
15use super::theme::ThemeColorTag;
16
17/// What Handlebars sees: base data, page data, what the server computed, and
18/// the caller's own.
19///
20/// Borrowed throughout — nothing is copied that was not computed. `A` is the
21/// caller's data, reachable in templates as `{{app.…}}`; pass `()` for none.
22#[derive(Debug, Serialize)]
23pub struct TemplateData<'a, A>
24where
25    A: Serialize,
26{
27    // ── site identity, from BaseTemplateData ──────────────────────────────
28    pub project: &'a str,
29    pub author: &'a str,
30    pub base_url: &'a str,
31    pub twitter_username: &'a str,
32    pub language_code: &'a str,
33    pub country_code: &'a str,
34    pub see_also: &'a [String],
35    pub copyright_start: &'a str,
36
37    // ── merged: page wins over base ───────────────────────────────────────
38    pub description: &'a str,
39    /// The path as declared, for a page that wants the asset itself — a CSS
40    /// background, say. Use `social_image_url` for anything a crawler reads.
41    pub social_image: &'a str,
42    pub social_image_alt: &'a str,
43    pub style_sheets: Vec<&'a str>,
44    pub scripts: Vec<&'a str>,
45
46    // ── page identity ─────────────────────────────────────────────────────
47    pub page_name: &'a str,
48    pub page_url: &'a str,
49    pub robots: &'a str,
50    /// `website`, or `article` when the page carries article metadata.
51    pub og_type: &'static str,
52    pub article: Option<&'a Article>,
53    /// Escaped for direct embedding in a `<script>` block.
54    pub jsonld: Option<String>,
55
56    // ── computed per render ───────────────────────────────────────────────
57    /// `"{page_name} | {project}"` — the `<title>`, always.
58    pub display_name: String,
59    /// `"{base_url}{page_url}"`.
60    pub canonical_url: String,
61    /// `"{language_code}_{country_code}"`, for `og:locale`.
62    pub locale: String,
63    /// Absolute and content-hashed. Open Graph requires an absolute URL: a
64    /// crawler fetching the page in isolation cannot resolve a relative one, so
65    /// a relative `og:image` silently yields no share card at all.
66    pub social_image_url: String,
67    pub social_image_width: Option<u32>,
68    pub social_image_height: Option<u32>,
69    pub social_image_type: Option<&'static str>,
70    /// Whether to link `favicon.svg`.
71    pub has_svg_icon: bool,
72    /// Distinct cross-origin hosts referenced by the stylesheets and scripts
73    /// below, so the connection is warm before the request that needs it.
74    pub preconnect: Vec<String>,
75    /// Computed per render, never cached — a long-running server would
76    /// otherwise keep claiming last year after New Year's.
77    pub copyright_end: i32,
78
79    // ── theme ─────────────────────────────────────────────────────────────
80    pub color_scheme: &'static str,
81    pub theme_colors: Vec<ThemeColorTag>,
82
83    // ── per-server, from FrontendRuntime ──────────────────────────────────
84    pub theme_script: &'a str,
85    pub analytics: Option<&'a super::frontend::AnalyticsPaths>,
86    pub sentry_browser: Option<&'a super::frontend::SentryBrowser>,
87
88    // ── context ───────────────────────────────────────────────────────────
89    pub environment: Environment,
90    /// Original asset path to content-hashed path.
91    pub cache_buster: &'a BTreeMap<String, String>,
92    pub app: A,
93}
94
95impl<'a, A> TemplateData<'a, A>
96where
97    A: Serialize,
98{
99    /// # Errors
100    ///
101    /// [`TemplateError::JsonLd`] if the page's JSON-LD document cannot be
102    /// serialized.
103    pub fn assemble(
104        base: &'a BaseTemplateData,
105        page: &'a PageTemplateData,
106        frontend: Option<&'a FrontendRuntime>,
107        environment: Environment,
108        cache_buster: &'a BTreeMap<String, String>,
109        app: A,
110    ) -> Result<Self, TemplateError> {
111        let canonical_url: String = format!("{}{}", base.base_url(), page.page_url());
112        let social_image: &str = page.social_image().unwrap_or_else(|| base.social_image());
113        let social_image_url: String = absolute_asset(base.base_url(), social_image, cache_buster);
114
115        let document: Option<Value> =
116            merge_jsonld(default_jsonld(base, page, &social_image_url), page.jsonld());
117        let jsonld: Option<String> = match document {
118            None => None,
119            Some(document) => Some(jsonld_script(&document, page.page_url())?),
120        };
121
122        let style_sheets: Vec<&str> = page.resolve_style_sheets(base.style_sheets());
123        let scripts: Vec<&str> = page.resolve_scripts(base.scripts());
124        let preconnect: Vec<String> = preconnect_origins(&style_sheets, &scripts);
125
126        Ok(Self {
127            project: base.project(),
128            author: base.author(),
129            base_url: base.base_url(),
130            twitter_username: base.twitter_username(),
131            language_code: base.language_code(),
132            country_code: base.country_code(),
133            see_also: base.see_also(),
134            copyright_start: base.copyright_start(),
135
136            description: page.description().unwrap_or_else(|| base.description()),
137            social_image,
138            social_image_alt: page
139                .social_image_alt()
140                .unwrap_or_else(|| base.social_image_alt()),
141            style_sheets,
142            scripts,
143
144            page_name: page.page_name(),
145            page_url: page.page_url(),
146            robots: page.robots(),
147            og_type: page.og_type(),
148            article: page.article(),
149            jsonld,
150
151            display_name: format!("{} | {}", page.page_name(), base.project()),
152            canonical_url,
153            locale: format!("{}_{}", base.language_code(), base.country_code()),
154            social_image_url,
155            social_image_width: frontend.and_then(|f| f.social_image.width),
156            social_image_height: frontend.and_then(|f| f.social_image.height),
157            social_image_type: frontend.and_then(|f| f.social_image.mime_type),
158            has_svg_icon: frontend.is_some_and(|f| f.has_svg_icon),
159            preconnect,
160            copyright_end: Utc::now().year(),
161
162            color_scheme: base.theme_color().color_scheme(),
163            theme_colors: base.theme_color().tags(),
164
165            theme_script: frontend.map_or("", |f| f.theme_script.as_str()),
166            analytics: frontend.map(|f| &f.analytics),
167            sentry_browser: frontend.map(|f| &f.sentry_browser),
168
169            environment,
170            cache_buster,
171            app,
172        })
173    }
174}
175
176/// Resolves an asset path to an absolute, content-hashed URL.
177///
178/// Falls back to the unhashed path when the asset is not in the manifest, which
179/// is what happens for an absolute URL a project supplied itself.
180fn absolute_asset(base_url: &str, path: &str, cache_buster: &BTreeMap<String, String>) -> String {
181    if path.starts_with("http://") || path.starts_with("https://") {
182        return path.to_string();
183    }
184    let key: &str = path.trim_start_matches('/');
185    let resolved: &str = cache_buster.get(key).map_or(key, String::as_str);
186    format!("{base_url}/{resolved}")
187}
188
189/// The distinct origins of every absolute URL in `style_sheets` and `scripts`,
190/// in first-seen order.
191///
192/// Derived rather than configured: a project that adds a CDN stylesheet gets
193/// the resource hint without knowing resource hints exist.
194fn preconnect_origins(style_sheets: &[&str], scripts: &[&str]) -> Vec<String> {
195    let mut origins: Vec<String> = Vec::new();
196    for url in style_sheets.iter().chain(scripts.iter()) {
197        let Some(origin) = origin_of(url) else {
198            continue;
199        };
200        if !origins.contains(&origin) {
201            origins.push(origin);
202        }
203    }
204    origins
205}
206
207/// `https://cdn.example.com/a/b.css` → `https://cdn.example.com`.
208fn origin_of(url: &str) -> Option<String> {
209    let scheme_end: usize = url.find("://")? + 3;
210    let authority: &str = &url[scheme_end..];
211    let end: usize = authority.find('/').unwrap_or(authority.len());
212    Some(format!("{}{}", &url[..scheme_end], &authority[..end]))
213}
214
215/// The site's own structured data, emitted on the home page only.
216///
217/// Google requires `WebSite` markup to live at the domain root and recommends
218/// the same for the entity node, so putting either on every page would be
219/// wrong rather than merely redundant.
220fn default_jsonld(
221    base: &BaseTemplateData,
222    page: &PageTemplateData,
223    social_image_url: &str,
224) -> Option<Value> {
225    if page.page_url() != "/" {
226        return None;
227    }
228
229    let website_id: String = format!("{}/#website", base.base_url());
230    let entity_id: String = format!("{}/#entity", base.base_url());
231
232    Some(serde_json::json!({
233        "@context": "https://schema.org",
234        "@graph": [
235            {
236                "@id": website_id,
237                "@type": "WebSite",
238                "name": base.project(),
239                "url": format!("{}/", base.base_url()),
240                "description": base.description(),
241                "publisher": { "@id": entity_id },
242            },
243            {
244                "@id": entity_id,
245                "@type": base.site_entity().schema_type(),
246                "name": base.site_entity().name(),
247                "url": format!("{}/", base.base_url()),
248                "logo": social_image_url,
249                // `sameAs` is how Google reconciles this entity with the
250                // profiles it already knows about.
251                "sameAs": base.see_also(),
252            },
253        ],
254    }))
255}
256
257/// Combines the library's default graph with whatever a page supplied.
258///
259/// Nodes are matched by `@id`, falling back to `@type`. A supplied node's
260/// properties are shallow-merged over the default's, so naming one property
261/// does not silently discard the rest; a supplied node matching nothing is
262/// appended.
263fn merge_jsonld(default: Option<Value>, supplied: Option<&Value>) -> Option<Value> {
264    match (default, supplied) {
265        (None, None) => None,
266        (None, Some(supplied)) => Some(supplied.clone()),
267        (Some(default), None) => Some(default),
268        (Some(default), Some(supplied)) => {
269            let mut nodes: Vec<Value> = graph_nodes(&default);
270            for node in graph_nodes(supplied) {
271                match nodes.iter_mut().find(|existing| same_node(existing, &node)) {
272                    Some(existing) => shallow_merge(existing, &node),
273                    None => nodes.push(node),
274                }
275            }
276            Some(serde_json::json!({
277                "@context": "https://schema.org",
278                "@graph": nodes,
279            }))
280        }
281    }
282}
283
284/// Flattens a document into its nodes, whether or not it uses `@graph`.
285fn graph_nodes(document: &Value) -> Vec<Value> {
286    match document.get("@graph") {
287        Some(Value::Array(nodes)) => nodes.clone(),
288        _ => match document {
289            Value::Array(nodes) => nodes.clone(),
290            other => vec![other.clone()],
291        },
292    }
293}
294
295/// Two nodes are the same when their `@id`s match, or — absent an `@id` — their
296/// `@type`s do.
297fn same_node(left: &Value, right: &Value) -> bool {
298    match (left.get("@id"), right.get("@id")) {
299        (Some(left), Some(right)) => left == right,
300        _ => match (left.get("@type"), right.get("@type")) {
301            (Some(left), Some(right)) => left == right,
302            _ => false,
303        },
304    }
305}
306
307/// Copies `source`'s properties over `target`'s, one level deep.
308fn shallow_merge(target: &mut Value, source: &Value) {
309    let (Some(target), Some(source)) = (target.as_object_mut(), source.as_object()) else {
310        return;
311    };
312    for (key, value) in source {
313        target.insert(key.clone(), value.clone());
314    }
315}
316
317/// Serializes JSON-LD safely for a `<script type="application/ld+json">` block.
318///
319/// `<`, `>` and `&` are hex-escaped: without it a `</script>` inside a string
320/// value closes the element early and the rest is parsed as markup. They never
321/// appear as JSON structural tokens, so the document stays valid.
322fn jsonld_script(document: &Value, page_url: &str) -> Result<String, TemplateError> {
323    let mut document: Value = document.clone();
324    if let Some(object) = document.as_object_mut()
325        && !object.contains_key("@context")
326    {
327        object.insert(
328            String::from("@context"),
329            Value::String(String::from("https://schema.org")),
330        );
331    }
332
333    let serialized: String =
334        serde_json::to_string(&document).map_err(|source| TemplateError::JsonLd {
335            page: page_url.to_string(),
336            source,
337        })?;
338
339    Ok(serialized
340        .replace('<', "\\u003c")
341        .replace('>', "\\u003e")
342        .replace('&', "\\u0026"))
343}
344
345#[cfg(test)]
346mod tests {
347    use std::collections::BTreeMap;
348
349    use serde_json::{Value, json};
350
351    use crate::Environment;
352    use crate::templates::base::{BaseTemplateData, BaseTemplateDataParams};
353    use crate::templates::page::PageTemplateData;
354    use crate::templates::site_entity::SiteEntity;
355    use crate::templates::theme::ThemeColor;
356
357    use super::{TemplateData, absolute_asset, jsonld_script, origin_of, preconnect_origins};
358
359    fn base() -> BaseTemplateData {
360        BaseTemplateData::new(BaseTemplateDataParams {
361            project: String::from("Todd Griffin"),
362            description: String::from("Base description."),
363            author: String::from("Todd Everett Griffin"),
364            base_url: String::from("https://www.toddgriffin.me"),
365            twitter_username: String::from("goddtriffin"),
366            social_image: String::from("static/image/social/card.webp"),
367            social_image_alt: String::from("Base card"),
368            theme_color: ThemeColor::light_dark("#fafafa", "#121212"),
369            theme_fallback: crate::templates::Fallback::Dark,
370            site_entity: SiteEntity::person("Todd Everett Griffin"),
371            language_code: String::from("en"),
372            country_code: String::from("US"),
373            see_also: vec![String::from("https://x.com/goddtriffin")],
374            copyright_start: String::from("1998"),
375            style_sheets: vec![String::from("static/stylesheet/main.css")],
376            scripts: vec![String::from("static/script/main.js")],
377        })
378    }
379
380    fn hashed() -> BTreeMap<String, String> {
381        let mut cache: BTreeMap<String, String> = BTreeMap::new();
382        cache.insert(
383            String::from("static/image/social/card.webp"),
384            String::from("static/image/social/card.abc123.webp"),
385        );
386        cache
387    }
388
389    #[test]
390    fn the_social_image_url_is_absolute_and_content_hashed() {
391        let base: BaseTemplateData = base();
392        let page: PageTemplateData = PageTemplateData::new("home", "Home", "/");
393        let cache: BTreeMap<String, String> = hashed();
394
395        let actual: TemplateData<'_, ()> =
396            TemplateData::assemble(&base, &page, None, Environment::Local, &cache, ())
397                .expect("assembles");
398
399        // Open Graph cannot resolve a relative path, and the hash is what lets
400        // the card be cached forever.
401        let expected: String =
402            String::from("https://www.toddgriffin.me/static/image/social/card.abc123.webp");
403        assert_eq!(expected, actual.social_image_url);
404    }
405
406    #[test]
407    fn an_unhashed_social_image_still_resolves_to_an_absolute_url() {
408        let expected: String = String::from("https://a.com/static/card.webp");
409        let actual: String = absolute_asset("https://a.com", "/static/card.webp", &BTreeMap::new());
410        assert_eq!(expected, actual);
411    }
412
413    #[test]
414    fn an_absolute_asset_url_is_left_alone() {
415        let expected: String = String::from("https://cdn.example.com/card.png");
416        let actual: String = absolute_asset(
417            "https://a.com",
418            "https://cdn.example.com/card.png",
419            &BTreeMap::new(),
420        );
421        assert_eq!(expected, actual);
422    }
423
424    #[test]
425    fn preconnect_names_each_cross_origin_host_once() {
426        let expected: Vec<String> = vec![String::from("https://cdnjs.cloudflare.com")];
427        let actual: Vec<String> = preconnect_origins(
428            &[
429                "static/stylesheet/main.css",
430                "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/styles/a.css",
431            ],
432            &["https://cdnjs.cloudflare.com/ajax/libs/highlight.js/hl.js"],
433        );
434        assert_eq!(expected, actual);
435    }
436
437    #[test]
438    fn a_site_with_no_cross_origin_assets_gets_no_hints() {
439        let expected: Vec<String> = Vec::new();
440        let actual: Vec<String> =
441            preconnect_origins(&["static/stylesheet/main.css"], &["static/script/main.js"]);
442        assert_eq!(expected, actual);
443    }
444
445    #[test]
446    fn an_origin_stops_at_the_first_path_segment() {
447        let expected: Option<String> = Some(String::from("https://cdn.example.com:8443"));
448        let actual: Option<String> = origin_of("https://cdn.example.com:8443/a/b.css");
449        assert_eq!(expected, actual);
450    }
451
452    #[test]
453    fn the_computed_fields_are_what_the_layout_expects() {
454        let base: BaseTemplateData = base();
455        let page: PageTemplateData = PageTemplateData::new("404", "404", "/404");
456        let cache: BTreeMap<String, String> = BTreeMap::new();
457
458        let actual: TemplateData<'_, ()> =
459            TemplateData::assemble(&base, &page, None, Environment::Local, &cache, ())
460                .expect("assembles");
461
462        assert_eq!(String::from("404 | Todd Griffin"), actual.display_name);
463        assert_eq!(
464            String::from("https://www.toddgriffin.me/404"),
465            actual.canonical_url
466        );
467        assert_eq!(String::from("en_US"), actual.locale);
468        assert_eq!("light dark", actual.color_scheme);
469        assert_eq!(2, actual.theme_colors.len());
470        assert_eq!("website", actual.og_type);
471    }
472
473    #[test]
474    fn the_default_graph_is_emitted_on_the_home_page_only() {
475        let base: BaseTemplateData = base();
476        let cache: BTreeMap<String, String> = BTreeMap::new();
477
478        let home: PageTemplateData = PageTemplateData::new("home", "Home", "/");
479        let rendered: TemplateData<'_, ()> =
480            TemplateData::assemble(&base, &home, None, Environment::Local, &cache, ())
481                .expect("assembles");
482        let jsonld: String = rendered.jsonld.expect("the home page gets a default graph");
483        assert!(jsonld.contains("WebSite"));
484        assert!(jsonld.contains("Person"));
485        // `sameAs` is what Google reconciles the entity against.
486        assert!(jsonld.contains("sameAs"));
487        assert!(jsonld.contains("https://x.com/goddtriffin"));
488
489        // Google requires WebSite markup at the domain root, so anywhere else
490        // it would be wrong rather than merely redundant.
491        let inner: PageTemplateData = PageTemplateData::new("blog", "Blog", "/blog");
492        let rendered: TemplateData<'_, ()> =
493            TemplateData::assemble(&base, &inner, None, Environment::Local, &cache, ())
494                .expect("assembles");
495        assert_eq!(None, rendered.jsonld);
496    }
497
498    #[test]
499    fn a_page_supplied_node_merges_over_the_default_without_discarding_it() {
500        let base: BaseTemplateData = base();
501        let cache: BTreeMap<String, String> = BTreeMap::new();
502        let home: PageTemplateData =
503            PageTemplateData::new("home", "Home", "/").with_jsonld(json!({
504                "@id": "https://www.toddgriffin.me/#website",
505                "name": "Overridden",
506                "alternateName": "TG",
507            }));
508
509        let rendered: TemplateData<'_, ()> =
510            TemplateData::assemble(&base, &home, None, Environment::Local, &cache, ())
511                .expect("assembles");
512        let jsonld: String = rendered.jsonld.expect("a graph");
513
514        // supplied wins
515        assert!(jsonld.contains("Overridden"));
516        assert!(!jsonld.contains("Todd Griffin\","));
517        // supplied adds
518        assert!(jsonld.contains("alternateName"));
519        // unmentioned defaults survive
520        assert!(jsonld.contains("WebSite"));
521        assert!(jsonld.contains("Person"));
522    }
523
524    #[test]
525    fn a_page_supplied_node_matching_nothing_is_appended() {
526        let base: BaseTemplateData = base();
527        let cache: BTreeMap<String, String> = BTreeMap::new();
528        let home: PageTemplateData = PageTemplateData::new("home", "Home", "/")
529            .with_jsonld(json!({ "@type": "WebApplication", "name": "Finder" }));
530
531        let rendered: TemplateData<'_, ()> =
532            TemplateData::assemble(&base, &home, None, Environment::Local, &cache, ())
533                .expect("assembles");
534        let jsonld: String = rendered.jsonld.expect("a graph");
535
536        assert!(jsonld.contains("WebApplication"));
537        assert!(jsonld.contains("WebSite"));
538        assert!(jsonld.contains("Person"));
539    }
540
541    #[test]
542    fn a_non_home_page_may_still_supply_its_own_document() {
543        let base: BaseTemplateData = base();
544        let cache: BTreeMap<String, String> = BTreeMap::new();
545        let post: PageTemplateData = PageTemplateData::new("blog-post", "A Post", "/blog/a")
546            .with_jsonld(json!({ "@type": "BlogPosting", "headline": "A Post" }));
547
548        let rendered: TemplateData<'_, ()> =
549            TemplateData::assemble(&base, &post, None, Environment::Local, &cache, ())
550                .expect("assembles");
551        let jsonld: String = rendered.jsonld.expect("the page supplied one");
552
553        assert!(jsonld.contains("BlogPosting"));
554        assert!(!jsonld.contains("WebSite"));
555    }
556
557    #[test]
558    fn jsonld_cannot_break_out_of_its_script_block() {
559        let hostile: Value = json!({
560            "@type": "DefinedTerm",
561            "name": "</script><img src=x onerror=alert(1)>",
562        });
563
564        let actual: String = jsonld_script(&hostile, "/finder/word").expect("serializes");
565
566        assert!(!actual.contains("</script>"), "the closing tag survived");
567        assert!(!actual.contains('<'), "a raw < survived");
568        assert!(!actual.contains('>'), "a raw > survived");
569        assert!(actual.contains("\\u003c"), "escaping did not happen");
570    }
571
572    #[test]
573    fn app_data_is_namespaced_rather_than_flattened() {
574        #[derive(serde::Serialize)]
575        struct BlogView {
576            posts: Vec<&'static str>,
577        }
578
579        let base: BaseTemplateData = base();
580        let page: PageTemplateData = PageTemplateData::new("blog", "Blog", "/blog");
581        let cache: BTreeMap<String, String> = BTreeMap::new();
582
583        let data: TemplateData<'_, BlogView> = TemplateData::assemble(
584            &base,
585            &page,
586            None,
587            Environment::Local,
588            &cache,
589            BlogView {
590                posts: vec!["first"],
591            },
592        )
593        .expect("assembles");
594
595        let serialized: Value = serde_json::to_value(&data).expect("serializes");
596        let expected: Value = json!(["first"]);
597        let actual: Value = serialized["app"]["posts"].clone();
598        assert_eq!(expected, actual);
599    }
600}