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, Copy, Default, PartialEq, Eq)]
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 to the left while the incoming page
27    /// slides in from the right — the same direction for every navigation,
28    /// including the browser Back button. mini-static injects the CSS this
29    /// needs (keyframes, and the `mix-blend-mode: normal` override the
30    /// browser's default cross-fade blend mode requires to look like a clean
31    /// slide instead of a wash between the two pages) — no site CSS required.
32    Slide,
33}
34
35/// Escape `input` for embedding as the contents of a double-quoted JS string
36/// literal that itself sits inside an HTML `<script>` element.
37///
38/// Beyond the usual JS string escapes (backslash, double quote, control
39/// characters), every `/` is escaped to `\/`. This is what keeps a selector
40/// containing `</script` (or any casing/spacing HTML's tokenizer would
41/// recognize as a script end tag) from prematurely closing the surrounding
42/// `<script>` element and corrupting the rest of the served page — the
43/// literal three-byte sequence `</s` never survives escaping, since the `/`
44/// immediately after `<` is always turned into `\/`.
45fn 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
59/// Render `root_selector` as the JS expression `spa_script_tag`'s generated
60/// `ROOT_SELECTOR` constant is assigned: an escaped, double-quoted string
61/// literal for `Some`, or the bare `null` for `None` (swap target is
62/// `document.body`).
63fn 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
70/// The `<style>` tag `Server` injects alongside [`spa_script_tag`] when
71/// [`SpaTransition::Slide`] is configured — empty string for
72/// [`SpaTransition::Fade`], which relies entirely on the browser's built-in
73/// cross-fade and needs no CSS.
74///
75/// `mix-blend-mode: normal` overrides the browser's default
76/// `mix-blend-mode: plus-lighter` on both pseudo-elements: that default
77/// exists to make the *cross-fade* blend cleanly while old and new are both
78/// semi-transparent and fully overlapping, but a slide has old and new
79/// passing through the same screen region at full opacity mid-animation —
80/// left additive, that overlap washes out into a fade-like blend instead of
81/// a clean push.
82fn 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
100/// The `<script>` tag `Server` injects into served HTML pages when spa-mode
101/// is enabled (see [`crate::Server::with_spa_mode`] /
102/// [`crate::Server::with_spa_root`]).
103///
104/// Intercepts left-clicks on same-origin `<a href>` elements (skipping ones
105/// with a non-`_self` `target`, a `download` attribute, `rel="external"`,
106/// [`SPA_OPT_OUT_ATTR`], or a same-page hash-only href), fetches the target
107/// URL, and — if the response is a successful `text/html` document — swaps
108/// the configured root element's `innerHTML` for the fetched document's
109/// corresponding content, updates the title, and pushes the new URL via
110/// `history.pushState`, instead of letting the browser navigate normally.
111///
112/// The fetch always runs to completion *before* any view transition starts:
113/// `document.startViewTransition()` (used when supported, with a plain
114/// synchronous swap as the fallback) wraps only the synchronous DOM mutation
115/// and history/scroll/event-dispatch step, never the network round trip —
116/// the View Transitions API expects its update callback to resolve
117/// immediately, not after an awaited fetch.
118///
119/// A non-OK response, a non-`text/html` response, or a fetch error all fall
120/// back to a real `location.href` navigation — spa-mode degrades to normal
121/// navigation, it never renders a broken page.
122///
123/// `curRoot.innerHTML = newRootHtml` is used to perform the swap — rather
124/// than replacing the root node itself — so that event listeners and
125/// attributes bound to the root element persist across navigations; this
126/// matters most for [`crate::Server::with_spa_root`], where the root is
127/// expected to be a long-lived container.
128///
129/// Back/forward (`popstate`) re-fetches and swaps to the new
130/// `location.href`, without pushing a new history entry, and animates
131/// identically to a forward navigation — the two are not distinguished.
132///
133/// # Panics
134///
135/// Never — the returned string is a fixed template with `root_selector`
136/// embedded through [`escape_js_string`].
137fn 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
201/// Insert the spa-mode client script (see [`spa_script_tag`]) — preceded by
202/// the transition's `<style>` tag, if any (see [`spa_transition_style_tag`])
203/// — into an HTML document, immediately before the closing `</body>` tag if
204/// one is found (checking both `</body>` and `</BODY>`), otherwise appended
205/// at the end of the document.
206///
207/// Operates on raw bytes, mirroring [`crate::reload::inject_reload_script`]
208/// exactly (both share [`find_subsequence`]) — `mini-static` has no HTML
209/// parser and does not need one for a single fixed-string insertion.
210pub(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        // The invariant that matters: no unescaped `</script` (case-insensitive)
247        // byte sequence survives -- that's the only sequence HTML's tokenizer
248        // treats specially while already inside a <script> element's raw text.
249        // An unescaped, slash-free `<script>` elsewhere is inert text there,
250        // not a second tag -- the tokenizer isn't scanning for tag-opens in
251        // that state, only for its own closing sequence.
252        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        // Pins the exact generated assignment: a bare `"` here would terminate
280        // the string literal early and corrupt the rest of the script.
281        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        // The only `</script` (case-insensitive) sequence allowed to survive
290        // is the genuine wrapper's own closing tag -- exactly one.
291        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}