Skip to main content

sofie_engine/
page.rs

1use crate::asset::{Asset, CLIENT_SCRIPT, ENGINE_SCRIPT, STYLESHEET};
2use crate::escape::push_escaped;
3use crate::language::Language;
4
5/// `allowEval` off removes eval paths, indicator styles off avoid an inline `<style>` the CSP
6/// blocks, and a zero history cache keeps personal data out of `localStorage`. Only a 5xx is an
7/// error htmx logs: the server sends 4xx as notices placed by `HX-Retarget`.
8const HTMX_CONFIG: &str = r#"<meta name="htmx-config" content='{"allowEval":false,"allowScriptTags":false,"selfRequestsOnly":true,"includeIndicatorStyles":false,"historyCacheSize":0,"responseHandling":[{"code":"204","swap":false},{"code":"[234]..","swap":true},{"code":"5..","swap":true,"error":true}]}'>"#;
9
10macro_rules! notice_region_open {
11    ($($attribute:literal)?) => {
12        concat!("<div id=\"sofie-notice\" role=\"status\" aria-live=\"polite\"", $(" ", $attribute,)?)
13    };
14}
15
16pub const NOTICE_TARGET: &str = "#sofie-notice";
17
18const TITLE_OPEN: &str = "<title>";
19const TITLE_CLOSE: &str = "</title>";
20
21/// Around what `<Head>` writes, which the page moves into `<head>`.
22#[cfg(feature = "content")]
23pub(crate) const HEAD_START: &str = "<!--sofie-head-->";
24#[cfg(feature = "content")]
25pub(crate) const HEAD_END: &str = "<!--/sofie-head-->";
26
27/// So a notice from an earlier failure does not outlive the next success.
28pub(crate) const CLEARED_NOTICE: &str =
29    concat!(notice_region_open!("hx-swap-oob=\"true\""), "></div>");
30
31/// engine.js's own notices: the attribute it reads and the key of its text.
32const SCRIPT_NOTICES: [(&str, &str); 5] = [
33    ("data-unreachable", "notice.unreachable"),
34    ("data-failed", "notice.failed"),
35    ("data-passkey-refused", "notice.passkey_refused"),
36    ("data-passkey-unsupported", "notice.passkey_unsupported"),
37    ("data-passkey-unused", "notice.passkey_unused"),
38];
39
40#[derive(Debug)]
41pub struct Page<'a> {
42    pub title: &'a str,
43    pub lang: &'a str,
44    pub body: &'a str,
45    /// Such as why a form posted without JavaScript was refused.
46    pub notice: &'a [String],
47    pub nonce: &'a str,
48    pub islands: Option<&'a IslandLinks>,
49    pub script_notices: ScriptNotices<'a>,
50}
51
52/// What engine.js says when a request fails, in the page's language.
53#[derive(Clone, Copy, Debug)]
54pub struct ScriptNotices<'a>(Language<'a>);
55
56impl ScriptNotices<'static> {
57    #[must_use]
58    pub fn plain() -> Self {
59        Self(Language::plain())
60    }
61}
62
63#[cfg(feature = "i18n")]
64impl<'a> ScriptNotices<'a> {
65    #[must_use]
66    pub fn new(language: crate::Language<'a>) -> Self {
67        Self(language)
68    }
69}
70
71impl ScriptNotices<'_> {
72    /// English is the script's own, so a page in English carries none.
73    fn push_attributes(self, html: &mut String) {
74        for (attribute, key) in SCRIPT_NOTICES {
75            let english = crate::english(key);
76            let text = self.0.text(key, english);
77            if text != english {
78                html.push(' ');
79                html.push_str(attribute);
80                html.push_str("=\"");
81                push_escaped(html, text);
82                html.push('"');
83            }
84        }
85    }
86}
87
88/// Outside the `islands` feature, so a `Page` has the same fields whichever crates enable it.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct IslandLinks {
91    pub script: String,
92    pub stylesheet: String,
93}
94
95impl IslandLinks {
96    pub(crate) fn push_head(&self, html: &mut String, nonce: &str) {
97        html.push_str("<link rel=\"stylesheet\" href=\"");
98        push_escaped(html, &self.stylesheet);
99        html.push_str("\" data-sofie-islands><script type=\"module\" src=\"");
100        push_escaped(html, &self.script);
101        html.push_str("\" nonce=\"");
102        push_escaped(html, nonce);
103        html.push_str("\" data-sofie-islands></script>");
104    }
105}
106
107impl Page<'_> {
108    #[must_use]
109    pub fn render(&self) -> String {
110        let (head, before, after) = self.split_head();
111        let (title, body) = take_title([before, after]);
112        let mut html = String::with_capacity(self.body.len() + 900);
113        html.push_str("<!doctype html><html lang=\"");
114        push_escaped(&mut html, self.lang);
115        html.push_str(
116            "\"><head><meta charset=\"utf-8\">\
117             <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
118        );
119        if let Some(title) = title {
120            html.push_str(title);
121        } else if !head.starts_with(TITLE_OPEN) {
122            html.push_str(TITLE_OPEN);
123            push_escaped(&mut html, self.title);
124            html.push_str(TITLE_CLOSE);
125        }
126        html.push_str(head);
127        html.push_str(HTMX_CONFIG);
128        html.push_str("<link rel=\"stylesheet\" href=\"");
129        STYLESHEET.push_path(&mut html);
130        html.push_str("\">");
131        self.push_script(&mut html, CLIENT_SCRIPT);
132        self.push_script(&mut html, ENGINE_SCRIPT);
133        if let Some(islands) = self.islands {
134            islands.push_head(&mut html, self.nonce);
135        }
136        html.push_str("</head><body>");
137        html.push_str(notice_region_open!());
138        self.script_notices.push_attributes(&mut html);
139        html.push('>');
140        push_notice(&mut html, self.notice);
141        html.push_str("</div>");
142        for part in body {
143            html.push_str(part);
144        }
145        html.push_str("</body></html>");
146        html
147    }
148
149    /// What a `<Head>` wrote, and the body before and after it.
150    #[cfg(feature = "content")]
151    fn split_head(&self) -> (&str, &str, &str) {
152        let body = self.body;
153        let found = body.find(HEAD_START).and_then(|start| {
154            let end = start + body[start..].find(HEAD_END)?;
155            Some((start, end))
156        });
157        let Some((start, end)) = found else {
158            return ("", body, "");
159        };
160        (
161            &body[start + HEAD_START.len()..end],
162            &body[..start],
163            &body[end + HEAD_END.len()..],
164        )
165    }
166
167    #[cfg(not(feature = "content"))]
168    fn split_head(&self) -> (&str, &str, &str) {
169        ("", self.body, "")
170    }
171
172    /// An asset path is the engine's own text and needs no escaping.
173    fn push_script(&self, html: &mut String, script: Asset) {
174        html.push_str("<script src=\"");
175        script.push_path(html);
176        html.push_str("\" nonce=\"");
177        push_escaped(html, self.nonce);
178        html.push_str("\" defer></script>");
179    }
180}
181
182/// A template's `<title>` and the body around it. Data is escaped and the parser lets a template
183/// write one `<title>` outside an `<svg>`, at its top level, so the first outside one is the
184/// template's; one inside names its image.
185fn take_title([before, after]: [&str; 2]) -> (Option<&str>, [&str; 3]) {
186    if let Some((title, start, end)) = split_title(before) {
187        return (Some(title), [start, end, after]);
188    }
189    if let Some((title, start, end)) = split_title(after) {
190        return (Some(title), [before, start, end]);
191    }
192    (None, [before, after, ""])
193}
194
195fn split_title(body: &str) -> Option<(&str, &str, &str)> {
196    let start = first_title_outside_svg(body)?;
197    let end = start + body[start..].find(TITLE_CLOSE)? + TITLE_CLOSE.len();
198    Some((&body[start..end], &body[..start], &body[end..]))
199}
200
201fn first_title_outside_svg(body: &str) -> Option<usize> {
202    let mut svg_depth = 0_isize;
203    for (start, _) in body.match_indices('<') {
204        let tag = &body[start..];
205        if opens_svg(tag) {
206            svg_depth += 1;
207        } else if tag.starts_with("</svg>") {
208            svg_depth -= 1;
209        } else if svg_depth == 0 && tag.starts_with(TITLE_OPEN) {
210            return Some(start);
211        }
212    }
213    None
214}
215
216fn opens_svg(tag: &str) -> bool {
217    tag.strip_prefix("<svg")
218        .is_some_and(|rest| rest.starts_with([' ', '\t', '\n', '\r', '>']))
219}
220
221#[must_use]
222pub fn with_cleared_notice(mut fragment: String) -> String {
223    fragment.push_str(CLEARED_NOTICE);
224    fragment
225}
226
227#[must_use]
228pub fn notice(messages: &[impl AsRef<str>]) -> String {
229    let mut html = String::new();
230    push_notice(&mut html, messages);
231    html
232}
233
234fn push_notice(html: &mut String, messages: &[impl AsRef<str>]) {
235    for message in messages {
236        html.push_str("<p>");
237        push_escaped(html, message.as_ref());
238        html.push_str("</p>");
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn the_notice_region_and_its_target_name_the_same_element() {
248        let id = NOTICE_TARGET.trim_start_matches('#');
249        assert!(notice_region_open!().contains(&format!("id=\"{id}\"")));
250        assert!(CLEARED_NOTICE.contains(&format!("id=\"{id}\"")));
251    }
252
253    fn page(script_notices: ScriptNotices<'_>) -> String {
254        Page {
255            title: "Orders",
256            lang: "tr",
257            body: "",
258            notice: &[],
259            nonce: "n",
260            islands: None,
261            script_notices,
262        }
263        .render()
264    }
265
266    #[test]
267    fn an_english_page_leaves_the_script_s_notices_to_it() {
268        assert!(page(ScriptNotices::plain()).contains("aria-live=\"polite\"></div>"));
269    }
270
271    #[cfg(feature = "i18n")]
272    #[test]
273    fn the_script_s_notices_are_written_in_the_page_s_language() {
274        let translations = crate::Translations::new(&["en", "tr"]).unwrap();
275        let turkish = translations.negotiate(None, Some("tr"));
276
277        let html = page(ScriptNotices::new(turkish));
278
279        assert!(
280            html.contains(
281                "<div id=\"sofie-notice\" role=\"status\" aria-live=\"polite\" \
282                 data-unreachable=\"Sunucuya ulaşılamadı. Lütfen yeniden deneyin.\" \
283                 data-failed=\"Bir şeyler ters gitti. Lütfen yeniden deneyin.\""
284            ),
285            "{html}"
286        );
287        assert!(html.contains("data-passkey-unused=\"Geçiş anahtarı kullanılmadı."));
288    }
289
290    #[test]
291    fn the_title_is_the_first_one_outside_every_svg() {
292        let body = "<svg><title>Icon</title><svg><title>Inner</title></svg></svg>\
293                    <title>Orders</title><svg><title>Row</title></svg>";
294
295        let (title, before, after) = split_title(body).unwrap();
296
297        assert_eq!(title, "<title>Orders</title>");
298        assert!(before.ends_with("</svg></svg>"));
299        assert_eq!(after, "<svg><title>Row</title></svg>");
300    }
301
302    #[test]
303    fn a_title_only_inside_svgs_is_no_page_title() {
304        assert!(split_title("<svg><title>Icon</title></svg><p>Text</p>").is_none());
305    }
306
307    #[test]
308    fn a_tag_only_starting_with_svg_opens_no_svg() {
309        let (title, _, _) = split_title("<svg-icon></svg-icon><title>Orders</title>").unwrap();
310        assert_eq!(title, "<title>Orders</title>");
311    }
312
313    #[test]
314    fn a_fragment_carries_an_out_of_band_empty_notice() {
315        let html = with_cleared_notice("<table></table>".to_owned());
316        assert!(html.starts_with("<table></table><div id=\"sofie-notice\""));
317        assert!(html.ends_with("hx-swap-oob=\"true\"></div>"));
318    }
319}