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