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;