use crate::reload::find_subsequence;
const SPA_NAVIGATE_EVENT: &str = "mini-static:navigate";
const SPA_OPT_OUT_ATTR: &str = "data-no-spa";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SpaTransition {
#[default]
Fade,
Slide,
}
fn escape_js_string(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'/' => out.push_str("\\/"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
fn root_selector_literal(root_selector: Option<&str>) -> String {
match root_selector {
Some(selector) => format!("\"{}\"", escape_js_string(selector)),
None => "null".to_string(),
}
}
fn spa_transition_style_tag(transition: SpaTransition) -> &'static str {
match transition {
SpaTransition::Fade => "",
SpaTransition::Slide => {
"<style>\
::view-transition-old(root),::view-transition-new(root){\
mix-blend-mode:normal;\
animation-duration:.3s;\
}\
::view-transition-old(root){animation-name:mini-static-slide-out;}\
::view-transition-new(root){animation-name:mini-static-slide-in;}\
@keyframes mini-static-slide-out{to{transform:translateX(-100%);}}\
@keyframes mini-static-slide-in{from{transform:translateX(100%);}}\
</style>"
}
}
}
fn spa_script_tag(root_selector: Option<&str>) -> String {
let root_selector_literal = root_selector_literal(root_selector);
format!(
"<script>(function(){{\
var ROOT_SELECTOR={root_selector_literal};\
var navToken=0;\
function root(doc){{return ROOT_SELECTOR?doc.querySelector(ROOT_SELECTOR):doc.body;}}\
function sameOrigin(url){{try{{return new URL(url,location.href).origin===location.origin;}}catch(e){{return false;}}}}\
function isHashOnly(a){{var u=new URL(a.href,location.href);return u.pathname===location.pathname&&u.search===location.search&&u.hash!==\"\";}}\
function shouldIntercept(a){{\
if(!a||!a.href)return false;\
if(a.hasAttribute(\"{SPA_OPT_OUT_ATTR}\"))return false;\
if(a.target&&a.target!==\"_self\")return false;\
if(a.hasAttribute(\"download\"))return false;\
if(a.getAttribute(\"rel\")===\"external\")return false;\
if(!sameOrigin(a.href))return false;\
if(isHashOnly(a))return false;\
return true;\
}}\
function scrollForUrl(url){{\
var hash=new URL(url,location.href).hash;\
if(hash){{\
var el=document.getElementById(hash.slice(1));\
if(el){{el.scrollIntoView();return;}}\
}}\
window.scrollTo(0,0);\
}}\
function navigate(url,push){{\
var token=++navToken;\
fetch(url).then(function(res){{\
var ct=res.headers.get(\"content-type\")||\"\";\
if(!res.ok||ct.indexOf(\"text/html\")===-1){{location.href=url;return null;}}\
return res.text().then(function(text){{return {{text:text,url:res.url}};}});\
}}).then(function(result){{\
if(!result||token!==navToken)return;\
var doc=new DOMParser().parseFromString(result.text,\"text/html\");\
var newRoot=root(doc);\
var curRoot=root(document);\
if(!newRoot||!curRoot){{location.href=result.url;return;}}\
var newRootHtml=newRoot.innerHTML;\
var newTitle=doc.title;\
function swap(){{\
curRoot.innerHTML=newRootHtml;\
document.title=newTitle;\
if(push)history.pushState({{}},\"\",result.url);\
scrollForUrl(result.url);\
window.dispatchEvent(new CustomEvent(\"{SPA_NAVIGATE_EVENT}\",{{detail:{{url:result.url}}}}));\
}}\
if(document.startViewTransition){{document.startViewTransition(swap);}}else{{swap();}}\
}}).catch(function(){{location.href=url;}});\
}}\
document.addEventListener(\"click\",function(e){{\
if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;\
var a=e.target&&e.target.closest?e.target.closest(\"a[href]\"):null;\
if(!shouldIntercept(a))return;\
e.preventDefault();\
navigate(a.href,true);\
}});\
window.addEventListener(\"popstate\",function(){{navigate(location.href,false);}});\
}})();</script>"
)
}
pub(crate) fn inject_spa_script(
html: &mut Vec<u8>,
root_selector: Option<&str>,
transition: SpaTransition,
) {
let mut injected = spa_transition_style_tag(transition).to_string();
injected.push_str(&spa_script_tag(root_selector));
let pos = find_subsequence(html, b"</body>").or_else(|| find_subsequence(html, b"</BODY>"));
match pos {
Some(pos) => {
html.splice(pos..pos, injected.into_bytes());
}
None => html.extend_from_slice(injected.as_bytes()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_js_string_escapes_backslash_quote_and_slash() {
assert_eq!(escape_js_string("a\\b"), "a\\\\b");
assert_eq!(escape_js_string("a\"b"), "a\\\"b");
assert_eq!(escape_js_string("a/b"), "a\\/b");
}
#[test]
fn escape_js_string_escapes_control_characters() {
assert_eq!(escape_js_string("a\u{0007}b"), "a\\u0007b");
}
#[test]
fn escape_js_string_breaks_up_a_closing_script_sequence() {
let escaped = escape_js_string("</script>");
assert_eq!(escaped, "<\\/script>");
assert!(!escaped.to_lowercase().contains("</script"));
}
#[test]
fn spa_script_tag_embeds_the_navigate_event_name() {
assert!(spa_script_tag(None).contains(SPA_NAVIGATE_EVENT));
assert!(spa_script_tag(Some("#app")).contains(SPA_NAVIGATE_EVENT));
}
#[test]
fn spa_script_tag_embeds_the_configured_root_selector() {
let script = spa_script_tag(Some("#app"));
assert!(script.contains("#app"));
}
#[test]
fn spa_script_tag_falls_back_to_document_body_when_no_root_configured() {
let script = spa_script_tag(None);
assert!(script.contains("doc.body"));
assert!(script.contains("ROOT_SELECTOR=null;"));
}
#[test]
fn spa_script_tag_embeds_the_selector_with_its_quote_escaped() {
let script = spa_script_tag(Some(r#"[data-x="y"]"#));
assert!(script.contains(r#"ROOT_SELECTOR="[data-x=\"y\"]";"#));
}
#[test]
fn spa_script_tag_neutralizes_a_closing_script_sequence_in_the_selector() {
let malicious = "</script><script>alert(1)</script>";
let script = spa_script_tag(Some(malicious));
let lower = script.to_lowercase();
assert_eq!(lower.matches("</script").count(), 1);
}
#[test]
fn spa_transition_style_tag_is_empty_for_fade() {
assert_eq!(spa_transition_style_tag(SpaTransition::Fade), "");
}
#[test]
fn spa_transition_style_tag_overrides_blend_mode_for_slide() {
let style = spa_transition_style_tag(SpaTransition::Slide);
assert!(style.contains("mix-blend-mode:normal"));
assert!(style.contains("::view-transition-old(root)"));
assert!(style.contains("::view-transition-new(root)"));
}
#[test]
fn inject_spa_script_inserts_before_closing_body_tag() {
let mut html = b"<html><body><h1>hi</h1></body></html>".to_vec();
inject_spa_script(&mut html, None, SpaTransition::Fade);
let s = String::from_utf8(html).unwrap();
assert!(s.starts_with("<html><body><h1>hi</h1>"));
assert!(s.ends_with("</body></html>"));
assert!(s.contains(SPA_NAVIGATE_EVENT));
assert!(s.find("<script>").unwrap() < s.find("</body>").unwrap());
}
#[test]
fn inject_spa_script_handles_uppercase_closing_tag() {
let mut html = b"<HTML><BODY>hi</BODY></HTML>".to_vec();
inject_spa_script(&mut html, None, SpaTransition::Fade);
let s = String::from_utf8(html).unwrap();
assert!(s.find("<script>").unwrap() < s.find("</BODY>").unwrap());
}
#[test]
fn inject_spa_script_appends_when_no_body_tag_present() {
let mut html = b"<h1>fragment, no body tag</h1>".to_vec();
inject_spa_script(&mut html, None, SpaTransition::Fade);
let s = String::from_utf8(html).unwrap();
assert!(s.starts_with("<h1>fragment, no body tag</h1>"));
assert!(s.ends_with("</script>"));
}
#[test]
fn inject_spa_script_is_safe_on_empty_input() {
let mut html: Vec<u8> = Vec::new();
inject_spa_script(&mut html, Some("#app"), SpaTransition::Fade);
let s = String::from_utf8(html).unwrap();
assert!(s.starts_with("<script>"));
assert!(s.ends_with("</script>"));
}
#[test]
fn inject_spa_script_prepends_the_style_tag_when_slide_is_configured() {
let mut html = b"<html><body></body></html>".to_vec();
inject_spa_script(&mut html, None, SpaTransition::Slide);
let s = String::from_utf8(html).unwrap();
assert!(s.contains("<style>"));
let style_pos = s.find("<style>").unwrap();
let script_pos = s.find("<script>").unwrap();
assert!(style_pos < script_pos);
}
#[test]
fn inject_spa_script_injects_no_style_tag_for_fade() {
let mut html = b"<html><body></body></html>".to_vec();
inject_spa_script(&mut html, None, SpaTransition::Fade);
let s = String::from_utf8(html).unwrap();
assert!(!s.contains("<style>"));
}
}