Skip to main content

sofie_engine/
islands.rs

1//! Props travel as JSON in a `<script type="application/json">` child, which the browser never
2//! runs, so they need no nonce and no attribute escaping.
3
4use serde_json::{Map, Value};
5use sofie_core::Shown;
6
7use crate::asset::{Asset, CSS, JAVASCRIPT};
8use crate::catalog::Kind;
9use crate::escape::push_script_json;
10use crate::render::{Resolved, each_field, items_of};
11use crate::syntax::{Attribute, Expression, Op, Path, Position, Root, Written, is_lowercase_name};
12use crate::{Template, TemplateError};
13
14/// Names the HTML standard reserves although they contain a hyphen.
15const RESERVED_NAMES: [&str; 8] = [
16    "annotation-xml",
17    "color-profile",
18    "font-face",
19    "font-face-src",
20    "font-face-uri",
21    "font-face-format",
22    "font-face-name",
23    "missing-glyph",
24];
25
26#[derive(Clone, Copy, Debug)]
27pub struct IslandBundle {
28    pub area: &'static str,
29    pub script: Asset,
30    pub stylesheet: Asset,
31}
32
33impl IslandBundle {
34    #[must_use]
35    pub const fn new(area: &'static str, script: &'static [u8], stylesheet: &'static [u8]) -> Self {
36        Self {
37            area,
38            script: Asset::new(area, "islands.js", script, JAVASCRIPT),
39            stylesheet: Asset::new(area, "islands.css", stylesheet, CSS),
40        }
41    }
42}
43
44pub use crate::page::IslandLinks;
45
46impl IslandLinks {
47    #[must_use]
48    pub fn of(bundle: &IslandBundle) -> Self {
49        Self {
50            script: bundle.script.path(),
51            stylesheet: bundle.stylesheet.path(),
52        }
53    }
54}
55
56/// Lowercase ASCII, as the island's file name is.
57#[must_use]
58pub fn is_island_name(name: &str) -> bool {
59    let mut bytes = name.bytes();
60    let starts_with_letter = bytes.next().is_some_and(|byte| byte.is_ascii_lowercase());
61    starts_with_letter
62        && name.contains('-')
63        && is_lowercase_name(name)
64        && !RESERVED_NAMES.contains(&name)
65}
66
67/// `<Island name="{tag}" props={data} />`, built without the parser.
68pub fn island_screen(key: &str, tag: &str) -> Result<Template, TemplateError> {
69    let attribute = |name: &str, value| Attribute {
70        name: name.to_owned(),
71        value,
72        position: Position::START,
73    };
74    let island = Written {
75        kind: Kind::Island,
76        attributes: vec![
77            attribute("name", Expression::Authored(tag.to_owned())),
78            attribute(
79                "props",
80                Expression::Path(Path {
81                    root: Root::Data,
82                    fields: Vec::new(),
83                }),
84            ),
85        ],
86        children: Vec::new(),
87        position: Position::START,
88    };
89    crate::compiled::build(key, vec![Op::Component(island)])
90}
91
92pub(crate) fn push_props(html: &mut String, props: &Value) {
93    html.push_str("<script type=\"application/json\">");
94    push_script_json(html, &props.to_string());
95    html.push_str("</script>");
96}
97
98/// What a path leading nowhere gives is an empty object, so the island mounts with its defaults.
99pub(crate) fn props_json(resolved: Resolved<'_>) -> Value {
100    resolved.map_or_else(|| Value::Object(Map::new()), shown_json)
101}
102
103fn shown_json(shown: Shown<'_>) -> Value {
104    match shown {
105        Shown::Null => Value::Null,
106        Shown::Bool(flag) => Value::Bool(flag),
107        Shown::Integer(number) => Value::from(number),
108        Shown::Float(number) => Value::from(number),
109        Shown::Number(number) => Value::Number(number.clone()),
110        Shown::Text(text) => Value::from(text),
111        Shown::Uuid(uuid) => Value::String(uuid.to_string()),
112        Shown::Instant(instant) => Value::String(instant.to_string()),
113        Shown::Formatted(display) => Value::String(display.to_string()),
114        Shown::List(_) => items_of(Some(shown)).map(shown_json).collect(),
115        Shown::Record(fields) => each_field(fields)
116            .map(|(name, value)| (name.to_owned(), shown_json(value)))
117            .collect(),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use serde_json::json;
124    use sofie_core::Show;
125
126    use super::*;
127
128    #[test]
129    fn props_are_the_json_of_what_they_show() {
130        let props = json!({"rows": [{"n": 1, "f": 0.5, "s": "x", "b": true, "z": null}], "m": {}});
131
132        assert_eq!(props_json(Some(props.show())), props);
133        assert_eq!(props_json(Some(Shown::Formatted(&7))), json!("7"));
134        assert_eq!(props_json(None), json!({}));
135    }
136
137    #[test]
138    fn island_names_are_custom_element_names() {
139        for name in ["order-chart", "a-1", "page-editor-2"] {
140            assert!(is_island_name(name), "{name}");
141        }
142        for name in [
143            "chart",
144            "Order-chart",
145            "1-chart",
146            "-chart",
147            "order_chart",
148            "order-chärt",
149            "font-face",
150            "",
151        ] {
152            assert!(!is_island_name(name), "{name}");
153        }
154    }
155
156    #[test]
157    fn props_cannot_close_their_script_or_open_a_comment() {
158        let props = json!({
159            "note": "</script><script>alert(1)</script><!-- & \u{2028}",
160            "count": 2
161        });
162
163        let mut html = String::new();
164        push_props(&mut html, &props);
165
166        assert_eq!(
167            html,
168            "<script type=\"application/json\">{\"count\":2,\"note\":\
169             \"\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e\\u003c!-- \
170             \\u0026 \\u2028\"}</script>"
171        );
172        let json = html
173            .strip_prefix("<script type=\"application/json\">")
174            .and_then(|rest| rest.strip_suffix("</script>"))
175            .unwrap();
176        assert_eq!(serde_json::from_str::<Value>(json).unwrap(), props);
177    }
178
179    #[test]
180    fn a_page_loads_the_bundle_with_its_nonce() {
181        let bundle = IslandBundle::new("member", b"export {};", b".a{}");
182        let mut head = String::new();
183
184        IslandLinks::of(&bundle).push_head(&mut head, "n0nce");
185
186        let script = bundle.script.path();
187        assert!(script.starts_with("/_sofie/member-") && script.ends_with(".islands.js"));
188        assert_eq!(
189            head,
190            format!(
191                "<link rel=\"stylesheet\" href=\"{}\" data-sofie-islands><script type=\"module\" \
192                 src=\"{script}\" nonce=\"n0nce\" data-sofie-islands></script>",
193                bundle.stylesheet.path()
194            )
195        );
196    }
197}