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