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, Default, PartialEq)]
pub enum SpaTransition {
#[default]
Fade,
Slide(SlideOptions),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SlideDirection {
#[default]
Forward,
Reverse,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SlideOptions {
duration_ms: u32,
direction: SlideDirection,
easing: String,
}
impl Default for SlideOptions {
fn default() -> Self {
Self {
duration_ms: 300,
direction: SlideDirection::default(),
easing: "ease".to_string(),
}
}
}
impl SlideOptions {
#[must_use]
pub fn duration_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = duration_ms;
self
}
#[must_use]
pub fn direction(mut self, direction: SlideDirection) -> Self {
self.direction = direction;
self
}
#[must_use]
pub fn easing(mut self, easing: impl Into<String>) -> Self {
self.easing = easing.into();
self
}
}
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 strip_style_breakout_chars(input: &str) -> String {
input.chars().filter(|&c| c != '<' && c != '>').collect()
}
fn spa_transition_style_tag(transition: &SpaTransition) -> String {
match transition {
SpaTransition::Fade => String::new(),
SpaTransition::Slide(options) => {
let (exit_transform, enter_transform) = match options.direction {
SlideDirection::Forward => ("translateX(-100%)", "translateX(100%)"),
SlideDirection::Reverse => ("translateX(100%)", "translateX(-100%)"),
};
let easing = strip_style_breakout_chars(&options.easing);
format!(
"<style>\
::view-transition-old(root),::view-transition-new(root){{\
mix-blend-mode:normal;\
animation-duration:{}ms;\
animation-timing-function:{easing};\
}}\
::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:{exit_transform};}}}}\
@keyframes mini-static-slide-in{{from{{transform:{enter_transform};}}}}\
</style>",
options.duration_ms
)
}
}
}
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);
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)]
#[path = "../tests/unit/spa.rs"]
mod tests;