1use crate::asset::{Asset, CLIENT_SCRIPT, ENGINE_SCRIPT, STYLESHEET};
2use crate::escape::push_escaped;
3use crate::language::Language;
4
5const 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#[cfg(feature = "content")]
23pub(crate) const HEAD_START: &str = "<!--sofie-head-->";
24#[cfg(feature = "content")]
25pub(crate) const HEAD_END: &str = "<!--/sofie-head-->";
26
27pub(crate) const CLEARED_NOTICE: &str =
29 concat!(notice_region_open!("hx-swap-oob=\"true\""), "></div>");
30
31const SCRIPT_NOTICES: [(&str, &str, &str); 5] = [
33 (
34 "data-unreachable",
35 "notice.unreachable",
36 "The server could not be reached. Try again.",
37 ),
38 (
39 "data-failed",
40 "notice.failed",
41 "Something went wrong. Try again.",
42 ),
43 (
44 "data-passkey-refused",
45 "notice.passkey_refused",
46 "The passkey was not accepted. Try again.",
47 ),
48 (
49 "data-passkey-unsupported",
50 "notice.passkey_unsupported",
51 "This browser cannot use passkeys.",
52 ),
53 (
54 "data-passkey-unused",
55 "notice.passkey_unused",
56 "The passkey was not used. Try again.",
57 ),
58];
59
60#[derive(Debug)]
61pub struct Page<'a> {
62 pub title: &'a str,
63 pub lang: &'a str,
64 pub body: &'a str,
65 pub notice: &'a [String],
67 pub nonce: &'a str,
68 pub islands: Option<&'a IslandLinks>,
69 pub script_notices: ScriptNotices<'a>,
70}
71
72#[derive(Clone, Copy, Debug)]
74pub struct ScriptNotices<'a>(Language<'a>);
75
76impl ScriptNotices<'static> {
77 #[must_use]
78 pub fn plain() -> Self {
79 Self(Language::plain())
80 }
81}
82
83#[cfg(feature = "i18n")]
84impl<'a> ScriptNotices<'a> {
85 #[must_use]
86 pub fn new(language: crate::Language<'a>) -> Self {
87 Self(language)
88 }
89}
90
91impl ScriptNotices<'_> {
92 fn push_attributes(self, html: &mut String) {
94 for (attribute, key, english) in SCRIPT_NOTICES {
95 let text = self.0.text(key, english);
96 if text != english {
97 html.push(' ');
98 html.push_str(attribute);
99 html.push_str("=\"");
100 push_escaped(html, text);
101 html.push('"');
102 }
103 }
104 }
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct IslandLinks {
110 pub script: String,
111 pub stylesheet: String,
112}
113
114impl IslandLinks {
115 pub(crate) fn push_head(&self, html: &mut String, nonce: &str) {
116 html.push_str("<link rel=\"stylesheet\" href=\"");
117 push_escaped(html, &self.stylesheet);
118 html.push_str("\" data-sofie-islands><script type=\"module\" src=\"");
119 push_escaped(html, &self.script);
120 html.push_str("\" nonce=\"");
121 push_escaped(html, nonce);
122 html.push_str("\" data-sofie-islands></script>");
123 }
124}
125
126impl Page<'_> {
127 #[must_use]
128 pub fn render(&self) -> String {
129 let (head, before, after) = self.split_head();
130 let (title, body) = take_title([before, after]);
131 let mut html = String::with_capacity(self.body.len() + 900);
132 html.push_str("<!doctype html><html lang=\"");
133 push_escaped(&mut html, self.lang);
134 html.push_str(
135 "\"><head><meta charset=\"utf-8\">\
136 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
137 );
138 if let Some(title) = title {
139 html.push_str(title);
140 } else if !head.starts_with(TITLE_OPEN) {
141 html.push_str(TITLE_OPEN);
142 push_escaped(&mut html, self.title);
143 html.push_str(TITLE_CLOSE);
144 }
145 html.push_str(head);
146 html.push_str(HTMX_CONFIG);
147 html.push_str("<link rel=\"stylesheet\" href=\"");
148 STYLESHEET.push_path(&mut html);
149 html.push_str("\">");
150 self.push_script(&mut html, CLIENT_SCRIPT);
151 self.push_script(&mut html, ENGINE_SCRIPT);
152 if let Some(islands) = self.islands {
153 islands.push_head(&mut html, self.nonce);
154 }
155 html.push_str("</head><body>");
156 html.push_str(notice_region_open!());
157 self.script_notices.push_attributes(&mut html);
158 html.push('>');
159 push_notice(&mut html, self.notice);
160 html.push_str("</div>");
161 for part in body {
162 html.push_str(part);
163 }
164 html.push_str("</body></html>");
165 html
166 }
167
168 #[cfg(feature = "content")]
170 fn split_head(&self) -> (&str, &str, &str) {
171 let body = self.body;
172 let found = body.find(HEAD_START).and_then(|start| {
173 let end = start + body[start..].find(HEAD_END)?;
174 Some((start, end))
175 });
176 let Some((start, end)) = found else {
177 return ("", body, "");
178 };
179 (
180 &body[start + HEAD_START.len()..end],
181 &body[..start],
182 &body[end + HEAD_END.len()..],
183 )
184 }
185
186 #[cfg(not(feature = "content"))]
187 fn split_head(&self) -> (&str, &str, &str) {
188 ("", self.body, "")
189 }
190
191 fn push_script(&self, html: &mut String, script: Asset) {
193 html.push_str("<script src=\"");
194 script.push_path(html);
195 html.push_str("\" nonce=\"");
196 push_escaped(html, self.nonce);
197 html.push_str("\" defer></script>");
198 }
199}
200
201fn take_title([before, after]: [&str; 2]) -> (Option<&str>, [&str; 3]) {
205 if let Some((title, start, end)) = split_title(before) {
206 return (Some(title), [start, end, after]);
207 }
208 if let Some((title, start, end)) = split_title(after) {
209 return (Some(title), [before, start, end]);
210 }
211 (None, [before, after, ""])
212}
213
214fn split_title(body: &str) -> Option<(&str, &str, &str)> {
215 let start = first_title_outside_svg(body)?;
216 let end = start + body[start..].find(TITLE_CLOSE)? + TITLE_CLOSE.len();
217 Some((&body[start..end], &body[..start], &body[end..]))
218}
219
220fn first_title_outside_svg(body: &str) -> Option<usize> {
221 let mut svg_depth = 0_isize;
222 for (start, _) in body.match_indices('<') {
223 let tag = &body[start..];
224 if tag.starts_with("<svg") {
225 svg_depth += 1;
226 } else if tag.starts_with("</svg>") {
227 svg_depth -= 1;
228 } else if svg_depth == 0 && tag.starts_with(TITLE_OPEN) {
229 return Some(start);
230 }
231 }
232 None
233}
234
235#[must_use]
236pub fn with_cleared_notice(mut fragment: String) -> String {
237 fragment.push_str(CLEARED_NOTICE);
238 fragment
239}
240
241#[must_use]
242pub fn notice(messages: &[impl AsRef<str>]) -> String {
243 let mut html = String::new();
244 push_notice(&mut html, messages);
245 html
246}
247
248fn push_notice(html: &mut String, messages: &[impl AsRef<str>]) {
249 for message in messages {
250 html.push_str("<p>");
251 push_escaped(html, message.as_ref());
252 html.push_str("</p>");
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn the_notice_region_and_its_target_name_the_same_element() {
262 let id = NOTICE_TARGET.trim_start_matches('#');
263 assert!(notice_region_open!().contains(&format!("id=\"{id}\"")));
264 assert!(CLEARED_NOTICE.contains(&format!("id=\"{id}\"")));
265 }
266
267 fn page(script_notices: ScriptNotices<'_>) -> String {
268 Page {
269 title: "Orders",
270 lang: "tr",
271 body: "",
272 notice: &[],
273 nonce: "n",
274 islands: None,
275 script_notices,
276 }
277 .render()
278 }
279
280 #[test]
281 fn an_english_page_leaves_the_script_s_notices_to_it() {
282 assert!(page(ScriptNotices::plain()).contains("aria-live=\"polite\"></div>"));
283 }
284
285 #[cfg(feature = "i18n")]
286 #[test]
287 fn the_script_s_notices_are_written_in_the_page_s_language() {
288 let translations = crate::Translations::new(&["en", "tr"]).unwrap();
289 let turkish = translations.negotiate(None, Some("tr"));
290
291 let html = page(ScriptNotices::new(turkish));
292
293 assert!(
294 html.contains(
295 "<div id=\"sofie-notice\" role=\"status\" aria-live=\"polite\" \
296 data-unreachable=\"Sunucuya ulaşılamadı. Lütfen yeniden deneyin.\" \
297 data-failed=\"Bir şeyler ters gitti. Lütfen yeniden deneyin.\""
298 ),
299 "{html}"
300 );
301 assert!(html.contains("data-passkey-unused=\"Geçiş anahtarı kullanılmadı."));
302 }
303
304 #[test]
305 fn the_title_is_the_first_one_outside_every_svg() {
306 let body = "<svg><title>Icon</title><svg><title>Inner</title></svg></svg>\
307 <title>Orders</title><svg><title>Row</title></svg>";
308
309 let (title, before, after) = split_title(body).unwrap();
310
311 assert_eq!(title, "<title>Orders</title>");
312 assert!(before.ends_with("</svg></svg>"));
313 assert_eq!(after, "<svg><title>Row</title></svg>");
314 }
315
316 #[test]
317 fn a_title_only_inside_svgs_is_no_page_title() {
318 assert!(split_title("<svg><title>Icon</title></svg><p>Text</p>").is_none());
319 }
320
321 #[test]
322 fn a_fragment_carries_an_out_of_band_empty_notice() {
323 let html = with_cleared_notice("<table></table>".to_owned());
324 assert!(html.starts_with("<table></table><div id=\"sofie-notice\""));
325 assert!(html.ends_with("hx-swap-oob=\"true\"></div>"));
326 }
327}