1//! The page around a site's content: the head (type, look, the preview's tags, the one script 2//! Datastar) and the body's open and close. 3//! 4//! A site writes what is inside `<body>` with `components` and passes it here with the 5//! attributes the body carries (Datastar's `data-signals`, say). Everything that varies between 6//! sites is a field, so there is one head. 7 8use maud::{DOCTYPE, Markup, PreEscaped, html}; 9 10use crate::asset; 11 12/// Where a page that loads Datastar (`Head::datastar`) asks for it. The site serves it there from 13/// `aldebaran_datastar::SCRIPT`: this crate does not carry the script. 14pub const DATASTAR_PATH: &str = "/datastar.js"; 15 16/// How the shared stylesheet and script reach the page. 17#[derive(Clone, Copy, Debug, PartialEq, Eq)] 18pub enum Assets { 19 /// `<link>` and `<script src>` to `/ui.css?v=…` and `/ui.js?v=…`. Needed when the site's 20 /// content security policy forbids inline script, as whiskers' does. 21 Linked, 22 /// The same bytes inside the page, as lmjtfy has them: one request fewer, and no policy 23 /// against inline script. 24 Inline, 25} 26 27/// One attribute on `<body>`. 28#[derive(Clone, Debug, PartialEq, Eq)] 29pub struct Attr { 30 name: String, 31 value: String, 32} 33 34impl Attr { 35 /// A name is letters, digits and `- _ . : ` only, which covers Datastar's `data-on:click__debounce.300ms`; 36 /// anything else would end the tag, so it stops the page being built. 37 pub fn new(name: &str, value: &str) -> Attr { 38 assert!( 39 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)), 40 "{name:?} is not an attribute name" 41 ); 42 Attr { name: name.to_owned(), value: value.to_owned() } 43 } 44} 45 46pub struct Head<'a> { 47 pub title: &'a str, 48 /// The preview's tags and any other `<meta>`: `preview::Preview::html`, a build id. 49 pub meta: Markup, 50 /// A site's own stylesheet text, inline after the shared one (it is the site's, small, and 51 /// changes with the site). 52 pub style: &'a str, 53 pub assets: Assets, 54 /// Whether the page loads Datastar, from `DATASTAR_PATH`, which the site serves. 55 pub datastar: bool, 56 /// An icon's address, if the site has one. 57 pub icon: Option<&'a str>, 58 /// Anything else the site needs in the head, after the shared parts (a linked sheet of its 59 /// own). 60 pub extra: Markup, 61} 62 63fn escape(text: &str) -> String { 64 text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) 65} 66 67/// The whole document: `body` is what goes inside `<body>`, before the shared script. 68pub fn document(head: &Head<'_>, body_attrs: &[Attr], body: Markup) -> Markup { 69 let mut open = String::from("<body"); 70 for Attr { name, value } in body_attrs { 71 open.push_str(&format!(" {name}=\"{}\"", escape(value))); 72 } 73 open.push('>'); 74 html! { 75 (DOCTYPE) 76 html lang="en" { 77 head { 78 meta charset="utf-8"; 79 meta name="viewport" content="width=device-width, initial-scale=1"; 80 title { (head.title) } 81 (head.meta) 82 @if let Some(icon) = head.icon { link rel="icon" type="image/svg+xml" href=(icon); } 83 // The two faces the first screen is set in, so it does not draw twice. 84 @for font in [&asset::FONTS[0], &asset::FONTS[2]] { 85 link rel="preload" href=(font.path) as="font" type="font/woff2" crossorigin; 86 } 87 @match head.assets { 88 Assets::Linked => { link rel="stylesheet" href=(asset::CSS.href()); } 89 Assets::Inline => { style { (PreEscaped(asset::CSS.text())) } } 90 } 91 @if !head.style.is_empty() { style { (PreEscaped(head.style)) } } 92 (head.extra) 93 @if head.datastar { script type="module" src=(DATASTAR_PATH) {} } 94 } 95 (PreEscaped(open)) 96 (body) 97 @match head.assets { 98 Assets::Linked => { script src=(asset::JS.href()) defer {} } 99 Assets::Inline => { script { (PreEscaped(asset::JS.text())) } } 100 } 101 (PreEscaped("</body>")) 102 } 103 } 104} 105 106#[cfg(test)] 107mod tests { 108 use super::*; 109 110 fn head(assets: Assets) -> Head<'static> { 111 Head { title: "T & T", meta: html! { meta name="description" content="d"; }, style: ".x{}", assets, datastar: true, icon: Some("/favicon.svg"), extra: html! {} } 112 } 113 114 #[test] 115 fn linked_assets_are_addresses_of_the_sites_own() { 116 let page = document(&head(Assets::Linked), &[], html! { p { "hi" } }).into_string(); 117 assert!(page.contains("<!DOCTYPE html>") && page.contains("<html lang=\"en\">")); 118 assert!(page.contains("<title>T & T</title>")); 119 assert!(page.contains(&format!("href=\"{}\"", asset::CSS.href()))); 120 assert!(page.contains(&format!("src=\"{}\"", asset::JS.href()))); 121 assert!(page.contains(&format!("src=\"{DATASTAR_PATH}\""))); 122 assert!(!page.contains("fonts.googleapis") && !page.contains("://"), "nothing is fetched from another site"); 123 assert!(!page.contains("<script>"), "no inline script when the assets are linked"); 124 assert!(page.ends_with("</body></html>")); 125 } 126 127 #[test] 128 fn inline_assets_carry_the_same_bytes() { 129 let page = document(&head(Assets::Inline), &[], html! { p { "hi" } }).into_string(); 130 assert!(page.contains(asset::CSS.text()) && page.contains(asset::JS.text())); 131 assert!(!page.contains("rel=\"stylesheet\"")); 132 } 133 134 #[test] 135 fn the_site_style_comes_after_the_shared_one_and_the_body_is_between_head_and_script() { 136 let page = document(&head(Assets::Linked), &[], html! { p #here { "hi" } }).into_string(); 137 assert!(page.find("rel=\"stylesheet\"").unwrap() < page.find(".x{}").unwrap()); 138 assert!(page.find("</head>").unwrap() < page.find("id=\"here\"").unwrap()); 139 assert!(page.find("id=\"here\"").unwrap() < page.find("/ui.js").unwrap()); 140 } 141 142 #[test] 143 fn body_attributes_are_escaped() { 144 let attrs = [Attr::new("data-signals", "{\"a\": \"<b>\"}"), Attr::new("data-on-interval__duration.1s", "x && y")]; 145 let page = document(&head(Assets::Linked), &attrs, html! {}).into_string(); 146 assert!(page.contains("<body data-signals=\"{"a": "<b>"}\" data-on-interval__duration.1s=\"x && y\">")); 147 } 148 149 #[test] 150 #[should_panic(expected = "is not an attribute name")] 151 fn an_attribute_name_that_would_end_the_tag_is_refused() { 152 Attr::new("a\"><script>", "x"); 153 } 154}