mini-static 0.20.0

A secure, async static file server with streaming, traversal protection, and connection limits.
Documentation
use crate::reload::find_subsequence;

/// The `CustomEvent` name the injected spa-mode script dispatches on
/// `window` after every client-side navigation (not the initial page load).
///
/// Site scripts listen for this to re-run per-page initialization that would
/// otherwise only execute once: content swapped in via `innerHTML` (see
/// [`spa_script_tag`]) never executes any `<script>` tags it contains.
const SPA_NAVIGATE_EVENT: &str = "mini-static:navigate";

/// The attribute an `<a>` element can carry to opt out of spa-mode
/// interception, falling through to a normal full-page navigation.
const SPA_OPT_OUT_ATTR: &str = "data-no-spa";

/// How spa-mode animates the swap between pages, set via
/// [`crate::Server::with_spa_transition`]. Both variants use the View
/// Transitions API when the browser supports it, and are a plain synchronous
/// swap (no animation) otherwise.
#[derive(Debug, Clone, Default, PartialEq)]
pub enum SpaTransition {
    /// The browser's default cross-fade, with no CSS injected. This is the
    /// default for `with_spa_mode()`/`with_spa_root()` when
    /// `with_spa_transition()` isn't called.
    #[default]
    Fade,
    /// The outgoing page slides out one side while the incoming page slides
    /// in from the other — the same direction for every navigation,
    /// including the browser Back button — per [`SlideOptions`]. mini-static
    /// injects the CSS this needs (keyframes, and the `mix-blend-mode:
    /// normal` override the browser's default cross-fade blend mode
    /// requires to look like a clean slide instead of a wash between the two
    /// pages) — no site CSS required.
    Slide(SlideOptions),
}

/// Which side the outgoing page exits toward, and the incoming page enters
/// from, for [`SpaTransition::Slide`]. The two are always opposite — there
/// is no independent control over the incoming side.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SlideDirection {
    /// Outgoing page exits to the left, incoming page enters from the
    /// right. Matches mini-static's original, undirectional slide.
    #[default]
    Forward,
    /// Outgoing page exits to the right, incoming page enters from the
    /// left — the mirror image of [`SlideDirection::Forward`].
    Reverse,
}

/// Tunable parameters for [`SpaTransition::Slide`]: how long the animation
/// runs, which way it slides, and its CSS easing curve.
///
/// Construct with [`SlideOptions::default`] and adjust only what you need
/// via the builder methods — the defaults reproduce mini-static's original,
/// non-configurable slide (300ms, [`SlideDirection::Forward`], `ease`).
///
/// # Example
///
/// ```
/// use mini_static::{SlideDirection, SlideOptions};
///
/// let options = SlideOptions::default()
///     .duration_ms(500)
///     .direction(SlideDirection::Reverse)
///     .easing("ease-in-out");
/// ```
#[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 {
    /// Set the animation's `animation-duration`, in milliseconds.
    #[must_use]
    pub fn duration_ms(mut self, duration_ms: u32) -> Self {
        self.duration_ms = duration_ms;
        self
    }

    /// Set which side the outgoing page exits toward.
    #[must_use]
    pub fn direction(mut self, direction: SlideDirection) -> Self {
        self.direction = direction;
        self
    }

    /// Set the animation's `animation-timing-function`, e.g. `"ease-in-out"`
    /// or `"cubic-bezier(0.4, 0, 0.2, 1)"`.
    ///
    /// Embedded into a `<style>` tag verbatim except for `<` and `>`, which
    /// are stripped — the only two characters that could otherwise break
    /// out of the surrounding `<style>` element (see
    /// [`spa_transition_style_tag`]'s use of [`strip_style_breakout_chars`]).
    /// An invalid CSS value here simply fails to animate; it can't corrupt
    /// the page.
    #[must_use]
    pub fn easing(mut self, easing: impl Into<String>) -> Self {
        self.easing = easing.into();
        self
    }
}

/// Escape `input` for embedding as the contents of a double-quoted JS string
/// literal that itself sits inside an HTML `<script>` element.
///
/// Beyond the usual JS string escapes (backslash, double quote, control
/// characters), every `/` is escaped to `\/`. This is what keeps a selector
/// containing `</script` (or any casing/spacing HTML's tokenizer would
/// recognize as a script end tag) from prematurely closing the surrounding
/// `<script>` element and corrupting the rest of the served page — the
/// literal three-byte sequence `</s` never survives escaping, since the `/`
/// immediately after `<` is always turned into `\/`.
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
}

/// Render `root_selector` as the JS expression `spa_script_tag`'s generated
/// `ROOT_SELECTOR` constant is assigned: an escaped, double-quoted string
/// literal for `Some`, or the bare `null` for `None` (swap target is
/// `document.body`).
fn root_selector_literal(root_selector: Option<&str>) -> String {
    match root_selector {
        Some(selector) => format!("\"{}\"", escape_js_string(selector)),
        None => "null".to_string(),
    }
}

/// Strip `<` and `>` from `input` — the only two characters that could let
/// a [`SlideOptions::easing`] value break out of the `<style>` element
/// [`spa_transition_style_tag`] embeds it in (the HTML tokenizer looks for
/// `</style` while inside a `<style>` element, before any CSS parsing
/// happens). Neither character is valid inside a CSS
/// `animation-timing-function` value, so stripping them cannot turn a valid
/// easing value into an invalid one.
fn strip_style_breakout_chars(input: &str) -> String {
    input.chars().filter(|&c| c != '<' && c != '>').collect()
}

/// The `<style>` tag `Server` injects alongside [`spa_script_tag`] when
/// [`SpaTransition::Slide`] is configured — empty string for
/// [`SpaTransition::Fade`], which relies entirely on the browser's built-in
/// cross-fade and needs no CSS.
///
/// `mix-blend-mode: normal` overrides the browser's default
/// `mix-blend-mode: plus-lighter` on both pseudo-elements: that default
/// exists to make the *cross-fade* blend cleanly while old and new are both
/// semi-transparent and fully overlapping, but a slide has old and new
/// passing through the same screen region at full opacity mid-animation —
/// left additive, that overlap washes out into a fade-like blend instead of
/// a clean push.
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
            )
        }
    }
}

/// The `<script>` tag `Server` injects into served HTML pages when spa-mode
/// is enabled (see [`crate::Server::with_spa_mode`] /
/// [`crate::Server::with_spa_root`]).
///
/// Intercepts left-clicks on same-origin `<a href>` elements (skipping ones
/// with a non-`_self` `target`, a `download` attribute, `rel="external"`,
/// [`SPA_OPT_OUT_ATTR`], or a same-page hash-only href), fetches the target
/// URL, and — if the response is a successful `text/html` document — swaps
/// the configured root element's `innerHTML` for the fetched document's
/// corresponding content, updates the title, and pushes the new URL via
/// `history.pushState`, instead of letting the browser navigate normally.
///
/// The fetch always runs to completion *before* any view transition starts:
/// `document.startViewTransition()` (used when supported, with a plain
/// synchronous swap as the fallback) wraps only the synchronous DOM mutation
/// and history/scroll/event-dispatch step, never the network round trip —
/// the View Transitions API expects its update callback to resolve
/// immediately, not after an awaited fetch.
///
/// A non-OK response, a non-`text/html` response, or a fetch error all fall
/// back to a real `location.href` navigation — spa-mode degrades to normal
/// navigation, it never renders a broken page.
///
/// `curRoot.innerHTML = newRootHtml` is used to perform the swap — rather
/// than replacing the root node itself — so that event listeners and
/// attributes bound to the root element persist across navigations; this
/// matters most for [`crate::Server::with_spa_root`], where the root is
/// expected to be a long-lived container.
///
/// Back/forward (`popstate`) re-fetches and swaps to the new
/// `location.href`, without pushing a new history entry, and animates
/// identically to a forward navigation — the two are not distinguished.
///
/// # Panics
///
/// Never — the returned string is a fixed template with `root_selector`
/// embedded through [`escape_js_string`].
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>"
    )
}

/// Insert the spa-mode client script (see [`spa_script_tag`]) — preceded by
/// the transition's `<style>` tag, if any (see [`spa_transition_style_tag`])
/// — into an HTML document, immediately before the closing `</body>` tag if
/// one is found (checking both `</body>` and `</BODY>`), otherwise appended
/// at the end of the document.
///
/// Operates on raw bytes, mirroring [`crate::reload::inject_reload_script`]
/// exactly (both share [`find_subsequence`]) — `mini-static` has no HTML
/// parser and does not need one for a single fixed-string insertion.
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;