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
88 pub environment: Environment,
90 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 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
176fn 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
189fn 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
207fn 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
215fn 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": base.see_also(),
252 },
253 ],
254 }))
255}
256
257fn 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
284fn 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
295fn 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
307fn 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
317fn 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 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 assert!(jsonld.contains("sameAs"));
487 assert!(jsonld.contains("https://x.com/goddtriffin"));
488
489 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 assert!(jsonld.contains("Overridden"));
516 assert!(!jsonld.contains("Todd Griffin\","));
517 assert!(jsonld.contains("alternateName"));
519 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}