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