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); 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 pub notice: &'a [String],
47 pub nonce: &'a str,
48 pub islands: Option<&'a IslandLinks>,
49 pub script_notices: ScriptNotices<'a>,
50}
51
52#[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 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#[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 #[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 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
182fn 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}