Skip to main content

sofie_engine/
clients.rs

1//! A client area's pages: one shell holding the first screen as JSON, and the area's bundle,
2//! which renders every later screen from its envelope.
3
4use serde_json::Value;
5
6use crate::asset::{Asset, CSS, JAVASCRIPT, STYLESHEET};
7use crate::escape::{push_escaped, push_script_json};
8use crate::page::IslandLinks;
9
10#[derive(Clone, Copy, Debug)]
11pub struct ClientBundle {
12    pub area: &'static str,
13    /// The screens compiled into it; any other screen of the area stays an HTML page.
14    pub screens: &'static [&'static str],
15    pub script: Asset,
16    pub stylesheet: Asset,
17}
18
19impl ClientBundle {
20    #[must_use]
21    pub const fn new(
22        area: &'static str,
23        screens: &'static [&'static str],
24        script: &'static [u8],
25        stylesheet: &'static [u8],
26    ) -> Self {
27        Self {
28            area,
29            screens,
30            script: Asset::new(area, "client.js", script, JAVASCRIPT),
31            stylesheet: Asset::new(area, "client.css", stylesheet, CSS),
32        }
33    }
34
35    #[must_use]
36    pub fn has_screen(&self, key: &str) -> bool {
37        self.screens.contains(&key)
38    }
39
40    #[must_use]
41    pub fn links(&self) -> ClientLinks {
42        ClientLinks {
43            script: self.script.path(),
44            stylesheet: self.stylesheet.path(),
45        }
46    }
47}
48
49/// Where the shell loads the bundle from: the embedded one, or the one `sofie dev` rebuilt.
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct ClientLinks {
52    pub script: String,
53    pub stylesheet: String,
54}
55
56/// The area's bundle the app's build script wrote to `OUT_DIR/clients`, with its compressed
57/// copies when built with them.
58#[macro_export]
59macro_rules! client_bundle {
60    ($area:literal, $screens:expr) => {{
61        let bundle = $crate::ClientBundle::new(
62            $area,
63            $screens,
64            include_bytes!(concat!(env!("OUT_DIR"), "/clients/", $area, ".js")),
65            include_bytes!(concat!(env!("OUT_DIR"), "/clients/", $area, ".css")),
66        );
67        $crate::ClientBundle {
68            script: $crate::compressed_asset!(
69                bundle.script,
70                concat!(env!("OUT_DIR"), "/clients/", $area, ".js")
71            ),
72            stylesheet: $crate::compressed_asset!(
73                bundle.stylesheet,
74                concat!(env!("OUT_DIR"), "/clients/", $area, ".css")
75            ),
76            ..bundle
77        }
78    }};
79}
80
81pub const SCREEN_ELEMENT: &str = "sofie-screen";
82
83#[derive(Debug)]
84pub struct ClientShell<'a> {
85    pub title: &'a str,
86    pub lang: &'a str,
87    pub nonce: &'a str,
88    pub envelope: &'a Value,
89    pub links: &'a ClientLinks,
90    pub islands: Option<&'a IslandLinks>,
91}
92
93impl ClientShell<'_> {
94    #[must_use]
95    pub fn render(&self) -> String {
96        let mut html = String::with_capacity(1024);
97        html.push_str("<!doctype html><html lang=\"");
98        push_escaped(&mut html, self.lang);
99        html.push_str(
100            "\"><head><meta charset=\"utf-8\">\
101             <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>",
102        );
103        push_escaped(&mut html, self.title);
104        html.push_str("</title>");
105        html.push_str("<link rel=\"stylesheet\" href=\"");
106        STYLESHEET.push_path(&mut html);
107        html.push_str("\"><link rel=\"stylesheet\" href=\"");
108        push_escaped(&mut html, &self.links.stylesheet);
109        html.push_str("\">");
110        if let Some(islands) = self.islands {
111            islands.push_head(&mut html, self.nonce);
112        }
113        html.push_str("<script type=\"module\" src=\"");
114        push_escaped(&mut html, &self.links.script);
115        html.push_str("\" nonce=\"");
116        push_escaped(&mut html, self.nonce);
117        html.push_str("\"></script></head><body><div id=\"sofie-app\"></div>");
118        html.push_str("<script type=\"application/json\" id=\"");
119        html.push_str(SCREEN_ELEMENT);
120        html.push_str("\">");
121        push_script_json(&mut html, self.envelope);
122        html.push_str("</script></body></html>");
123        html
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use serde_json::json;
130
131    use super::{ClientBundle, ClientShell};
132
133    #[test]
134    fn the_shell_carries_the_screen_as_inert_json_and_loads_the_bundle() {
135        let bundle = ClientBundle::new("member", &["order/member/orders"], b"x", b"y");
136        let envelope = json!({"screen": "order/member/orders", "data": {"note": "</script><b>"}});
137        let html = ClientShell {
138            title: "Orders",
139            lang: "en",
140            nonce: "n1",
141            envelope: &envelope,
142            links: &bundle.links(),
143            islands: None,
144        }
145        .render();
146
147        assert!(html.contains("<title>Orders</title>"), "{html}");
148        assert!(html.contains(&format!(
149            "<script type=\"module\" src=\"{}\" nonce=\"n1\"></script>",
150            bundle.script.path()
151        )));
152        assert!(
153            html.contains("\\u003c/script\\u003e\\u003cb\\u003e"),
154            "{html}"
155        );
156        assert!(bundle.has_screen("order/member/orders"));
157        assert!(!bundle.has_screen("order/member/other"));
158    }
159}