Skip to main content

mini_static/
spa.rs

1use crate::reload::find_subsequence;
2
3/// The `CustomEvent` name the injected spa-mode script dispatches on
4/// `window` after every client-side navigation (not the initial page load).
5///
6/// Site scripts listen for this to re-run per-page initialization that would
7/// otherwise only execute once: content swapped in via `innerHTML` (see
8/// [`spa_script_tag`]) never executes any `<script>` tags it contains.
9const SPA_NAVIGATE_EVENT: &str = "mini-static:navigate";
10
11/// The attribute an `<a>` element can carry to opt out of spa-mode
12/// interception, falling through to a normal full-page navigation.
13const SPA_OPT_OUT_ATTR: &str = "data-no-spa";
14
15/// How spa-mode animates the swap between pages, set via
16/// [`crate::Server::with_spa_transition`]. Both variants use the View
17/// Transitions API when the browser supports it, and are a plain synchronous
18/// swap (no animation) otherwise.
19#[derive(Debug, Clone, Default, PartialEq)]
20pub enum SpaTransition {
21    /// The browser's default cross-fade, with no CSS injected. This is the
22    /// default for `with_spa_mode()`/`with_spa_root()` when
23    /// `with_spa_transition()` isn't called.
24    #[default]
25    Fade,
26    /// The outgoing page slides out one side while the incoming page slides
27    /// in from the other — the same direction for every navigation,
28    /// including the browser Back button — per [`SlideOptions`]. mini-static
29    /// injects the CSS this needs (keyframes, and the `mix-blend-mode:
30    /// normal` override the browser's default cross-fade blend mode
31    /// requires to look like a clean slide instead of a wash between the two
32    /// pages) — no site CSS required.
33    Slide(SlideOptions),
34}
35
36/// Which side the outgoing page exits toward, and the incoming page enters
37/// from, for [`SpaTransition::Slide`]. The two are always opposite — there
38/// is no independent control over the incoming side.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub enum SlideDirection {
41    /// Outgoing page exits to the left, incoming page enters from the
42    /// right. Matches mini-static's original, undirectional slide.
43    #[default]
44    Forward,
45    /// Outgoing page exits to the right, incoming page enters from the
46    /// left — the mirror image of [`SlideDirection::Forward`].
47    Reverse,
48}
49
50/// Tunable parameters for [`SpaTransition::Slide`]: how long the animation
51/// runs, which way it slides, and its CSS easing curve.
52///
53/// Construct with [`SlideOptions::default`] and adjust only what you need
54/// via the builder methods — the defaults reproduce mini-static's original,
55/// non-configurable slide (300ms, [`SlideDirection::Forward`], `ease`).
56///
57/// # Example
58///
59/// ```
60/// use mini_static::{SlideDirection, SlideOptions};
61///
62/// let options = SlideOptions::default()
63///     .duration_ms(500)
64///     .direction(SlideDirection::Reverse)
65///     .easing("ease-in-out");
66/// ```
67#[derive(Debug, Clone, PartialEq)]
68pub struct SlideOptions {
69    duration_ms: u32,
70    direction: SlideDirection,
71    easing: String,
72}
73
74impl Default for SlideOptions {
75    fn default() -> Self {
76        Self {
77            duration_ms: 300,
78            direction: SlideDirection::default(),
79            easing: "ease".to_string(),
80        }
81    }
82}
83
84impl SlideOptions {
85    /// Set the animation's `animation-duration`, in milliseconds.
86    #[must_use]
87    pub fn duration_ms(mut self, duration_ms: u32) -> Self {
88        self.duration_ms = duration_ms;
89        self
90    }
91
92    /// Set which side the outgoing page exits toward.
93    #[must_use]
94    pub fn direction(mut self, direction: SlideDirection) -> Self {
95        self.direction = direction;
96        self
97    }
98
99    /// Set the animation's `animation-timing-function`, e.g. `"ease-in-out"`
100    /// or `"cubic-bezier(0.4, 0, 0.2, 1)"`.
101    ///
102    /// Embedded into a `<style>` tag verbatim except for `<` and `>`, which
103    /// are stripped — the only two characters that could otherwise break
104    /// out of the surrounding `<style>` element (see
105    /// [`spa_transition_style_tag`]'s use of [`strip_style_breakout_chars`]).
106    /// An invalid CSS value here simply fails to animate; it can't corrupt
107    /// the page.
108    #[must_use]
109    pub fn easing(mut self, easing: impl Into<String>) -> Self {
110        self.easing = easing.into();
111        self
112    }
113}
114
115/// Escape `input` for embedding as the contents of a double-quoted JS string
116/// literal that itself sits inside an HTML `<script>` element.
117///
118/// Beyond the usual JS string escapes (backslash, double quote, control
119/// characters), every `/` is escaped to `\/`. This is what keeps a selector
120/// containing `</script` (or any casing/spacing HTML's tokenizer would
121/// recognize as a script end tag) from prematurely closing the surrounding
122/// `<script>` element and corrupting the rest of the served page — the
123/// literal three-byte sequence `</s` never survives escaping, since the `/`
124/// immediately after `<` is always turned into `\/`.
125fn escape_js_string(input: &str) -> String {
126    let mut out = String::with_capacity(input.len());
127    for ch in input.chars() {
128        match ch {
129            '\\' => out.push_str("\\\\"),
130            '"' => out.push_str("\\\""),
131            '/' => out.push_str("\\/"),
132            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
133            c => out.push(c),
134        }
135    }
136    out
137}
138
139/// Render `root_selector` as the JS expression `spa_script_tag`'s generated
140/// `ROOT_SELECTOR` constant is assigned: an escaped, double-quoted string
141/// literal for `Some`, or the bare `null` for `None` (swap target is
142/// `document.body`).
143fn root_selector_literal(root_selector: Option<&str>) -> String {
144    match root_selector {
145        Some(selector) => format!("\"{}\"", escape_js_string(selector)),
146        None => "null".to_string(),
147    }
148}
149
150/// Strip `<` and `>` from `input` — the only two characters that could let
151/// a [`SlideOptions::easing`] value break out of the `<style>` element
152/// [`spa_transition_style_tag`] embeds it in (the HTML tokenizer looks for
153/// `</style` while inside a `<style>` element, before any CSS parsing
154/// happens). Neither character is valid inside a CSS
155/// `animation-timing-function` value, so stripping them cannot turn a valid
156/// easing value into an invalid one.
157fn strip_style_breakout_chars(input: &str) -> String {
158    input.chars().filter(|&c| c != '<' && c != '>').collect()
159}
160
161/// The `<style>` tag `Server` injects alongside [`spa_script_tag`] when
162/// [`SpaTransition::Slide`] is configured — empty string for
163/// [`SpaTransition::Fade`], which relies entirely on the browser's built-in
164/// cross-fade and needs no CSS.
165///
166/// `mix-blend-mode: normal` overrides the browser's default
167/// `mix-blend-mode: plus-lighter` on both pseudo-elements: that default
168/// exists to make the *cross-fade* blend cleanly while old and new are both
169/// semi-transparent and fully overlapping, but a slide has old and new
170/// passing through the same screen region at full opacity mid-animation —
171/// left additive, that overlap washes out into a fade-like blend instead of
172/// a clean push.
173fn spa_transition_style_tag(transition: &SpaTransition) -> String {
174    match transition {
175        SpaTransition::Fade => String::new(),
176        SpaTransition::Slide(options) => {
177            let (exit_transform, enter_transform) = match options.direction {
178                SlideDirection::Forward => ("translateX(-100%)", "translateX(100%)"),
179                SlideDirection::Reverse => ("translateX(100%)", "translateX(-100%)"),
180            };
181            let easing = strip_style_breakout_chars(&options.easing);
182            format!(
183                "<style>\
184					::view-transition-old(root),::view-transition-new(root){{\
185						mix-blend-mode:normal;\
186						animation-duration:{}ms;\
187						animation-timing-function:{easing};\
188					}}\
189					::view-transition-old(root){{animation-name:mini-static-slide-out;}}\
190					::view-transition-new(root){{animation-name:mini-static-slide-in;}}\
191					@keyframes mini-static-slide-out{{to{{transform:{exit_transform};}}}}\
192					@keyframes mini-static-slide-in{{from{{transform:{enter_transform};}}}}\
193					</style>",
194                options.duration_ms
195            )
196        }
197    }
198}
199
200/// The `<script>` tag `Server` injects into served HTML pages when spa-mode
201/// is enabled (see [`crate::Server::with_spa_mode`] /
202/// [`crate::Server::with_spa_root`]).
203///
204/// Intercepts left-clicks on same-origin `<a href>` elements (skipping ones
205/// with a non-`_self` `target`, a `download` attribute, `rel="external"`,
206/// [`SPA_OPT_OUT_ATTR`], or a same-page hash-only href), fetches the target
207/// URL, and — if the response is a successful `text/html` document — swaps
208/// the configured root element's `innerHTML` for the fetched document's
209/// corresponding content, updates the title, and pushes the new URL via
210/// `history.pushState`, instead of letting the browser navigate normally.
211///
212/// The fetch always runs to completion *before* any view transition starts:
213/// `document.startViewTransition()` (used when supported, with a plain
214/// synchronous swap as the fallback) wraps only the synchronous DOM mutation
215/// and history/scroll/event-dispatch step, never the network round trip —
216/// the View Transitions API expects its update callback to resolve
217/// immediately, not after an awaited fetch.
218///
219/// A non-OK response, a non-`text/html` response, or a fetch error all fall
220/// back to a real `location.href` navigation — spa-mode degrades to normal
221/// navigation, it never renders a broken page.
222///
223/// `curRoot.innerHTML = newRootHtml` is used to perform the swap — rather
224/// than replacing the root node itself — so that event listeners and
225/// attributes bound to the root element persist across navigations; this
226/// matters most for [`crate::Server::with_spa_root`], where the root is
227/// expected to be a long-lived container.
228///
229/// Back/forward (`popstate`) re-fetches and swaps to the new
230/// `location.href`, without pushing a new history entry, and animates
231/// identically to a forward navigation — the two are not distinguished.
232///
233/// # Panics
234///
235/// Never — the returned string is a fixed template with `root_selector`
236/// embedded through [`escape_js_string`].
237fn spa_script_tag(root_selector: Option<&str>) -> String {
238    let root_selector_literal = root_selector_literal(root_selector);
239
240    format!(
241        "<script>(function(){{\
242			var ROOT_SELECTOR={root_selector_literal};\
243			var navToken=0;\
244			function root(doc){{return ROOT_SELECTOR?doc.querySelector(ROOT_SELECTOR):doc.body;}}\
245			function sameOrigin(url){{try{{return new URL(url,location.href).origin===location.origin;}}catch(e){{return false;}}}}\
246			function isHashOnly(a){{var u=new URL(a.href,location.href);return u.pathname===location.pathname&&u.search===location.search&&u.hash!==\"\";}}\
247			function shouldIntercept(a){{\
248				if(!a||!a.href)return false;\
249				if(a.hasAttribute(\"{SPA_OPT_OUT_ATTR}\"))return false;\
250				if(a.target&&a.target!==\"_self\")return false;\
251				if(a.hasAttribute(\"download\"))return false;\
252				if(a.getAttribute(\"rel\")===\"external\")return false;\
253				if(!sameOrigin(a.href))return false;\
254				if(isHashOnly(a))return false;\
255				return true;\
256			}}\
257			function scrollForUrl(url){{\
258				var hash=new URL(url,location.href).hash;\
259				if(hash){{\
260					var el=document.getElementById(hash.slice(1));\
261					if(el){{el.scrollIntoView();return;}}\
262				}}\
263				window.scrollTo(0,0);\
264			}}\
265			function navigate(url,push){{\
266				var token=++navToken;\
267				fetch(url).then(function(res){{\
268					var ct=res.headers.get(\"content-type\")||\"\";\
269					if(!res.ok||ct.indexOf(\"text/html\")===-1){{location.href=url;return null;}}\
270					return res.text().then(function(text){{return {{text:text,url:res.url}};}});\
271				}}).then(function(result){{\
272					if(!result||token!==navToken)return;\
273					var doc=new DOMParser().parseFromString(result.text,\"text/html\");\
274					var newRoot=root(doc);\
275					var curRoot=root(document);\
276					if(!newRoot||!curRoot){{location.href=result.url;return;}}\
277					var newRootHtml=newRoot.innerHTML;\
278					var newTitle=doc.title;\
279					function swap(){{\
280						curRoot.innerHTML=newRootHtml;\
281						document.title=newTitle;\
282						if(push)history.pushState({{}},\"\",result.url);\
283						scrollForUrl(result.url);\
284						window.dispatchEvent(new CustomEvent(\"{SPA_NAVIGATE_EVENT}\",{{detail:{{url:result.url}}}}));\
285					}}\
286					if(document.startViewTransition){{document.startViewTransition(swap);}}else{{swap();}}\
287				}}).catch(function(){{location.href=url;}});\
288			}}\
289			document.addEventListener(\"click\",function(e){{\
290				if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;\
291				var a=e.target&&e.target.closest?e.target.closest(\"a[href]\"):null;\
292				if(!shouldIntercept(a))return;\
293				e.preventDefault();\
294				navigate(a.href,true);\
295			}});\
296			window.addEventListener(\"popstate\",function(){{navigate(location.href,false);}});\
297		}})();</script>"
298    )
299}
300
301/// Insert the spa-mode client script (see [`spa_script_tag`]) — preceded by
302/// the transition's `<style>` tag, if any (see [`spa_transition_style_tag`])
303/// — into an HTML document, immediately before the closing `</body>` tag if
304/// one is found (checking both `</body>` and `</BODY>`), otherwise appended
305/// at the end of the document.
306///
307/// Operates on raw bytes, mirroring [`crate::reload::inject_reload_script`]
308/// exactly (both share [`find_subsequence`]) — `mini-static` has no HTML
309/// parser and does not need one for a single fixed-string insertion.
310pub(crate) fn inject_spa_script(
311    html: &mut Vec<u8>,
312    root_selector: Option<&str>,
313    transition: &SpaTransition,
314) {
315    let mut injected = spa_transition_style_tag(transition);
316    injected.push_str(&spa_script_tag(root_selector));
317
318    let pos = find_subsequence(html, b"</body>").or_else(|| find_subsequence(html, b"</BODY>"));
319
320    match pos {
321        Some(pos) => {
322            html.splice(pos..pos, injected.into_bytes());
323        }
324        None => html.extend_from_slice(injected.as_bytes()),
325    }
326}
327
328#[cfg(test)]
329#[path = "../tests/unit/spa.rs"]
330mod tests;