1use 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#[derive(Debug, Serialize)]
23pub struct TemplateData<'a, A>
24where
25 A: Serialize,
26{
27 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 pub description: &'a str,
39 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 pub page_name: &'a str,
48 pub page_url: &'a str,
49 pub robots: &'a str,
50 pub og_type: &'static str,
52 pub article: Option<&'a Article>,
53 pub jsonld: Option<String>,
55
56 pub display_name: String,
59 pub canonical_url: String,
61 pub locale: String,
63 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 pub has_svg_icon: bool,
72 pub preconnect: Vec<String>,
75 pub copyright_end: i32,
78
79 pub color_scheme: &'static str,
81 pub theme_colors: Vec<ThemeColorTag>,
82
83 pub theme_script: &'a str,
85 pub analytics: Option<&'a super::frontend::AnalyticsPaths>,
86 pub sentry_browser: Option<&'a super::frontend::SentryBrowser>,
87 pub feed: Option<&'a super::frontend::FeedLinks>,
89
90 pub environment: Environment,
92 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 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
179fn 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
192fn 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
210fn 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
218fn 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": base.see_also(),
255 },
256 ],
257 }))
258}
259
260fn 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
287fn 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
298fn 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
310fn 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
320fn 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 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 assert!(jsonld.contains("sameAs"));
490 assert!(jsonld.contains("https://x.com/goddtriffin"));
491
492 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 assert!(jsonld.contains("Overridden"));
519 assert!(!jsonld.contains("Todd Griffin\","));
520 assert!(jsonld.contains("alternateName"));
522 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}