shell.rsannotatedshell.rssource154 lines · 6.5 KB · raw

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;

Where a page that loads Datastar (Head::datastar) asks for it. The site serves it there from aldebaran_datastar::SCRIPT: this crate does not carry the script.

14pub const DATASTAR_PATH: &str = "/datastar.js";

How the shared stylesheet and script reach the page.

17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Assets {

<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.

21    Linked,

The same bytes inside the page, as lmjtfy has them: one request fewer, and no policy against inline script.

24    Inline,
25}

One attribute on <body>.

28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Attr {
30    name: String,
31    value: String,
32}
34impl 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.

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}
46pub struct Head<'a> {
47    pub title: &'a str,

The preview's tags and any other <meta>: preview::Preview::html, a build id.

49    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).

52    pub style: &'a str,
53    pub assets: Assets,

Whether the page loads Datastar, from DATASTAR_PATH, which the site serves.

55    pub datastar: bool,

An icon's address, if the site has one.

57    pub icon: Option<&'a str>,

Anything else the site needs in the head, after the shared parts (a linked sheet of its own).

60    pub extra: Markup,
61}
63fn escape(text: &str) -> String {
64    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
65}

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}
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 &amp; 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=\"{&quot;a&quot;: &quot;&lt;b&gt;&quot;}\" data-on-interval__duration.1s=\"x &amp;&amp; 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}