1use crate::reload::find_subsequence;
2
3const SPA_NAVIGATE_EVENT: &str = "mini-static:navigate";
10
11const SPA_OPT_OUT_ATTR: &str = "data-no-spa";
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub enum SpaTransition {
21 #[default]
25 Fade,
26 Slide,
33}
34
35fn escape_js_string(input: &str) -> String {
46 let mut out = String::with_capacity(input.len());
47 for ch in input.chars() {
48 match ch {
49 '\\' => out.push_str("\\\\"),
50 '"' => out.push_str("\\\""),
51 '/' => out.push_str("\\/"),
52 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
53 c => out.push(c),
54 }
55 }
56 out
57}
58
59fn root_selector_literal(root_selector: Option<&str>) -> String {
64 match root_selector {
65 Some(selector) => format!("\"{}\"", escape_js_string(selector)),
66 None => "null".to_string(),
67 }
68}
69
70fn spa_transition_style_tag(transition: SpaTransition) -> &'static str {
83 match transition {
84 SpaTransition::Fade => "",
85 SpaTransition::Slide => {
86 "<style>\
87 ::view-transition-old(root),::view-transition-new(root){\
88 mix-blend-mode:normal;\
89 animation-duration:.3s;\
90 }\
91 ::view-transition-old(root){animation-name:mini-static-slide-out;}\
92 ::view-transition-new(root){animation-name:mini-static-slide-in;}\
93 @keyframes mini-static-slide-out{to{transform:translateX(-100%);}}\
94 @keyframes mini-static-slide-in{from{transform:translateX(100%);}}\
95 </style>"
96 }
97 }
98}
99
100fn spa_script_tag(root_selector: Option<&str>) -> String {
138 let root_selector_literal = root_selector_literal(root_selector);
139
140 format!(
141 "<script>(function(){{\
142 var ROOT_SELECTOR={root_selector_literal};\
143 var navToken=0;\
144 function root(doc){{return ROOT_SELECTOR?doc.querySelector(ROOT_SELECTOR):doc.body;}}\
145 function sameOrigin(url){{try{{return new URL(url,location.href).origin===location.origin;}}catch(e){{return false;}}}}\
146 function isHashOnly(a){{var u=new URL(a.href,location.href);return u.pathname===location.pathname&&u.search===location.search&&u.hash!==\"\";}}\
147 function shouldIntercept(a){{\
148 if(!a||!a.href)return false;\
149 if(a.hasAttribute(\"{SPA_OPT_OUT_ATTR}\"))return false;\
150 if(a.target&&a.target!==\"_self\")return false;\
151 if(a.hasAttribute(\"download\"))return false;\
152 if(a.getAttribute(\"rel\")===\"external\")return false;\
153 if(!sameOrigin(a.href))return false;\
154 if(isHashOnly(a))return false;\
155 return true;\
156 }}\
157 function scrollForUrl(url){{\
158 var hash=new URL(url,location.href).hash;\
159 if(hash){{\
160 var el=document.getElementById(hash.slice(1));\
161 if(el){{el.scrollIntoView();return;}}\
162 }}\
163 window.scrollTo(0,0);\
164 }}\
165 function navigate(url,push){{\
166 var token=++navToken;\
167 fetch(url).then(function(res){{\
168 var ct=res.headers.get(\"content-type\")||\"\";\
169 if(!res.ok||ct.indexOf(\"text/html\")===-1){{location.href=url;return null;}}\
170 return res.text().then(function(text){{return {{text:text,url:res.url}};}});\
171 }}).then(function(result){{\
172 if(!result||token!==navToken)return;\
173 var doc=new DOMParser().parseFromString(result.text,\"text/html\");\
174 var newRoot=root(doc);\
175 var curRoot=root(document);\
176 if(!newRoot||!curRoot){{location.href=result.url;return;}}\
177 var newRootHtml=newRoot.innerHTML;\
178 var newTitle=doc.title;\
179 function swap(){{\
180 curRoot.innerHTML=newRootHtml;\
181 document.title=newTitle;\
182 if(push)history.pushState({{}},\"\",result.url);\
183 scrollForUrl(result.url);\
184 window.dispatchEvent(new CustomEvent(\"{SPA_NAVIGATE_EVENT}\",{{detail:{{url:result.url}}}}));\
185 }}\
186 if(document.startViewTransition){{document.startViewTransition(swap);}}else{{swap();}}\
187 }}).catch(function(){{location.href=url;}});\
188 }}\
189 document.addEventListener(\"click\",function(e){{\
190 if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;\
191 var a=e.target&&e.target.closest?e.target.closest(\"a[href]\"):null;\
192 if(!shouldIntercept(a))return;\
193 e.preventDefault();\
194 navigate(a.href,true);\
195 }});\
196 window.addEventListener(\"popstate\",function(){{navigate(location.href,false);}});\
197 }})();</script>"
198 )
199}
200
201pub(crate) fn inject_spa_script(
211 html: &mut Vec<u8>,
212 root_selector: Option<&str>,
213 transition: SpaTransition,
214) {
215 let mut injected = spa_transition_style_tag(transition).to_string();
216 injected.push_str(&spa_script_tag(root_selector));
217
218 let pos = find_subsequence(html, b"</body>").or_else(|| find_subsequence(html, b"</BODY>"));
219
220 match pos {
221 Some(pos) => {
222 html.splice(pos..pos, injected.into_bytes());
223 }
224 None => html.extend_from_slice(injected.as_bytes()),
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn escape_js_string_escapes_backslash_quote_and_slash() {
234 assert_eq!(escape_js_string("a\\b"), "a\\\\b");
235 assert_eq!(escape_js_string("a\"b"), "a\\\"b");
236 assert_eq!(escape_js_string("a/b"), "a\\/b");
237 }
238
239 #[test]
240 fn escape_js_string_escapes_control_characters() {
241 assert_eq!(escape_js_string("a\u{0007}b"), "a\\u0007b");
242 }
243
244 #[test]
245 fn escape_js_string_breaks_up_a_closing_script_sequence() {
246 let escaped = escape_js_string("</script>");
253 assert_eq!(escaped, "<\\/script>");
254 assert!(!escaped.to_lowercase().contains("</script"));
255 }
256
257 #[test]
258 fn spa_script_tag_embeds_the_navigate_event_name() {
259 assert!(spa_script_tag(None).contains(SPA_NAVIGATE_EVENT));
260 assert!(spa_script_tag(Some("#app")).contains(SPA_NAVIGATE_EVENT));
261 }
262
263 #[test]
264 fn spa_script_tag_embeds_the_configured_root_selector() {
265 let script = spa_script_tag(Some("#app"));
266 assert!(script.contains("#app"));
267 }
268
269 #[test]
270 fn spa_script_tag_falls_back_to_document_body_when_no_root_configured() {
271 let script = spa_script_tag(None);
272 assert!(script.contains("doc.body"));
273 assert!(script.contains("ROOT_SELECTOR=null;"));
274 }
275
276 #[test]
277 fn spa_script_tag_embeds_the_selector_with_its_quote_escaped() {
278 let script = spa_script_tag(Some(r#"[data-x="y"]"#));
279 assert!(script.contains(r#"ROOT_SELECTOR="[data-x=\"y\"]";"#));
282 }
283
284 #[test]
285 fn spa_script_tag_neutralizes_a_closing_script_sequence_in_the_selector() {
286 let malicious = "</script><script>alert(1)</script>";
287 let script = spa_script_tag(Some(malicious));
288
289 let lower = script.to_lowercase();
292 assert_eq!(lower.matches("</script").count(), 1);
293 }
294
295 #[test]
296 fn spa_transition_style_tag_is_empty_for_fade() {
297 assert_eq!(spa_transition_style_tag(SpaTransition::Fade), "");
298 }
299
300 #[test]
301 fn spa_transition_style_tag_overrides_blend_mode_for_slide() {
302 let style = spa_transition_style_tag(SpaTransition::Slide);
303 assert!(style.contains("mix-blend-mode:normal"));
304 assert!(style.contains("::view-transition-old(root)"));
305 assert!(style.contains("::view-transition-new(root)"));
306 }
307
308 #[test]
309 fn inject_spa_script_inserts_before_closing_body_tag() {
310 let mut html = b"<html><body><h1>hi</h1></body></html>".to_vec();
311 inject_spa_script(&mut html, None, SpaTransition::Fade);
312 let s = String::from_utf8(html).unwrap();
313
314 assert!(s.starts_with("<html><body><h1>hi</h1>"));
315 assert!(s.ends_with("</body></html>"));
316 assert!(s.contains(SPA_NAVIGATE_EVENT));
317 assert!(s.find("<script>").unwrap() < s.find("</body>").unwrap());
318 }
319
320 #[test]
321 fn inject_spa_script_handles_uppercase_closing_tag() {
322 let mut html = b"<HTML><BODY>hi</BODY></HTML>".to_vec();
323 inject_spa_script(&mut html, None, SpaTransition::Fade);
324 let s = String::from_utf8(html).unwrap();
325
326 assert!(s.find("<script>").unwrap() < s.find("</BODY>").unwrap());
327 }
328
329 #[test]
330 fn inject_spa_script_appends_when_no_body_tag_present() {
331 let mut html = b"<h1>fragment, no body tag</h1>".to_vec();
332 inject_spa_script(&mut html, None, SpaTransition::Fade);
333 let s = String::from_utf8(html).unwrap();
334
335 assert!(s.starts_with("<h1>fragment, no body tag</h1>"));
336 assert!(s.ends_with("</script>"));
337 }
338
339 #[test]
340 fn inject_spa_script_is_safe_on_empty_input() {
341 let mut html: Vec<u8> = Vec::new();
342 inject_spa_script(&mut html, Some("#app"), SpaTransition::Fade);
343 let s = String::from_utf8(html).unwrap();
344
345 assert!(s.starts_with("<script>"));
346 assert!(s.ends_with("</script>"));
347 }
348
349 #[test]
350 fn inject_spa_script_prepends_the_style_tag_when_slide_is_configured() {
351 let mut html = b"<html><body></body></html>".to_vec();
352 inject_spa_script(&mut html, None, SpaTransition::Slide);
353 let s = String::from_utf8(html).unwrap();
354
355 assert!(s.contains("<style>"));
356 let style_pos = s.find("<style>").unwrap();
357 let script_pos = s.find("<script>").unwrap();
358 assert!(style_pos < script_pos);
359 }
360
361 #[test]
362 fn inject_spa_script_injects_no_style_tag_for_fade() {
363 let mut html = b"<html><body></body></html>".to_vec();
364 inject_spa_script(&mut html, None, SpaTransition::Fade);
365 let s = String::from_utf8(html).unwrap();
366
367 assert!(!s.contains("<style>"));
368 }
369}