1use 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 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#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct ClientLinks {
50 pub script: String,
51 pub stylesheet: String,
52}
53
54#[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}