1use 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
14const 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#[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
67pub 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
98pub(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}