Skip to main content

ssg/plugins/
view_transitions.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! View Transitions API + lazy navigation (issue #547).
5//!
6//! Opt-in via `transitions = true` in `ssg.toml`. When enabled, the
7//! plugin:
8//!
9//! 1. Writes `_transitions/ssg-transitions.js` to the site dir — a tiny
10//!    (~3 KB) client script that:
11//!    - Intercepts same-origin `<a>` clicks
12//!    - Fetches the next page, swaps its `<main>` content
13//!    - Wraps the swap in `document.startViewTransition(...)` where
14//!      supported (Chromium + Safari 18+ as of 2026-06)
15//!    - Falls back to the browser's default full-reload navigation in
16//!      non-supporting browsers (Firefox stable as of 2026-06)
17//!    - Skips cross-origin links, modified clicks
18//!      (`ctrl`/`cmd`/`shift`/`alt`), non-`GET` targets and any link
19//!      that opts out via `data-no-transition` or `target="_blank"`
20//! 2. Injects a `<script type="module" defer>` tag and a tiny
21//!    `<style>` block that names `header` and `footer` as persistent
22//!    transition roots (so they don't animate across navigations —
23//!    AC6).
24//! 3. Dispatches a `ssg:after-swap` `CustomEvent` after each swap so
25//!    the islands loader (and any other listener) can re-hydrate
26//!    components on the new page.
27//!
28//! ## Architecture
29//!
30//! Builds on:
31//!
32//! - `<ssg-island>` (`crate::plugins::islands`) — exposes
33//!   `connectedCallback` / `disconnectedCallback`, so swapping
34//!   `<main>` detaches old islands' listeners cleanly (AC5).
35//! - `LiveReloadPlugin` (`crate::server::livereload`) — in dev mode,
36//!   the reload handler is upgraded to wrap `location.reload()` in
37//!   `startViewTransition()` when structural changes occur (AC7).
38//!
39//! ## Bundle size budget
40//!
41//! The injected script must stay ≤ 5 KB uncompressed. Verified by a
42//! unit test in this file.
43
44use crate::cmd::SsgConfig;
45use crate::error::{PathErrorExt, SsgError};
46use crate::plugin::{Plugin, PluginContext};
47use crate::util::html_rewriter::inject_before_body_close_or_append;
48use std::{fs, path::Path};
49
50/// Where the client script is written, relative to `site_dir`.
51const SCRIPT_DIR: &str = "_transitions";
52
53/// Public script URL used by the injected `<script>` tag.
54const SCRIPT_URL: &str = "/_transitions/ssg-transitions.js";
55
56/// Filename of the client script (also the cache marker).
57const SCRIPT_FILENAME: &str = "ssg-transitions.js";
58
59/// HTML comment / attribute we use to detect prior injection so the
60/// plugin is idempotent (`transform_html` may run multiple times in
61/// some incremental-rebuild paths).
62const INJECT_MARKER: &str = "data-ssg-transitions";
63
64/// Hard ceiling on the script size — keeps the network cost minimal.
65/// Bumping this constant requires updating the issue acceptance budget.
66#[cfg(test)]
67const MAX_SCRIPT_BYTES: usize = 5 * 1024;
68
69/// Plugin that injects the View Transitions API client + style hooks.
70///
71/// Opt-in via [`SsgConfig::transitions`]; when `false` the plugin is
72/// never registered (see `core::pipeline::register_default_plugins`).
73///
74/// # Examples
75///
76/// ```
77/// use ssg::plugin::Plugin;
78/// use ssg::view_transitions::ViewTransitionsPlugin;
79/// assert_eq!(ViewTransitionsPlugin::new().name(), "view-transitions");
80/// ```
81#[derive(Debug, Clone, Copy, Default)]
82pub struct ViewTransitionsPlugin;
83
84impl ViewTransitionsPlugin {
85    /// Creates a new instance.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use ssg::view_transitions::ViewTransitionsPlugin;
91    /// let _plugin = ViewTransitionsPlugin::new();
92    /// ```
93    #[must_use]
94    pub const fn new() -> Self {
95        Self
96    }
97
98    /// Returns whether `cfg` opts in to transitions.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use ssg::cmd::SsgConfig;
104    /// use ssg::view_transitions::ViewTransitionsPlugin;
105    /// let cfg = SsgConfig::builder()
106    ///     .site_name("t".into())
107    ///     .base_url("http://example.com".into())
108    ///     .build()
109    ///     .unwrap();
110    /// assert!(!ViewTransitionsPlugin::enabled(&cfg));
111    /// ```
112    #[must_use]
113    pub const fn enabled(cfg: &SsgConfig) -> bool {
114        cfg.transitions
115    }
116}
117
118impl Plugin for ViewTransitionsPlugin {
119    fn name(&self) -> &'static str {
120        "view-transitions"
121    }
122
123    fn has_transform(&self) -> bool {
124        true
125    }
126
127    fn transform_html(
128        &self,
129        html: &str,
130        _path: &Path,
131        _ctx: &PluginContext,
132    ) -> Result<String, SsgError> {
133        if html.contains(INJECT_MARKER) {
134            return Ok(html.to_string());
135        }
136
137        // Don't inject into HTML fragments without a closing </body> —
138        // those are likely partials, not full pages.
139        if !html.contains("</body>") && !html.contains("</html>") {
140            return Ok(html.to_string());
141        }
142
143        let head_block = format!("    {INLINE_STYLE}\n");
144        let script_tag = format!(
145            "    <script type=\"module\" defer {INJECT_MARKER} src=\"{SCRIPT_URL}\"></script>\n"
146        );
147
148        // Inject style into <head> when present so persistent
149        // transition roots are named before paint.
150        // Parser-backed: a `</head>` inside a comment or script in the head
151        // is not the head's end tag, and a byte splice cannot tell the
152        // difference (ssg#540).
153        let with_style =
154            crate::util::head_dom::inject_before_head_close(html, &head_block);
155
156        // Inject script just before </body> so the DOM is ready.
157        let with_script =
158            inject_before_body_close_or_append(&with_style, &script_tag);
159
160        Ok(with_script)
161    }
162
163    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
164        if !ctx.site_dir.exists() {
165            return Ok(());
166        }
167        if ctx.dry_run {
168            return Ok(());
169        }
170
171        let dir = ctx.site_dir.join(SCRIPT_DIR);
172        fs::create_dir_all(&dir).with_path(&dir)?;
173
174        let path = dir.join(SCRIPT_FILENAME);
175        fs::write(&path, VIEW_TRANSITIONS_JS).with_path(&path)?;
176
177        log::info!(
178            "[view-transitions] wrote client script ({} bytes)",
179            VIEW_TRANSITIONS_JS.len(),
180        );
181        Ok(())
182    }
183}
184
185/// Inline `<style>` block that names persistent transition roots.
186///
187/// Browsers without View Transitions support ignore the property
188/// silently, so this is safe to ship unconditionally.
189const INLINE_STYLE: &str = "<style data-ssg-transitions-style>\
190header[role=\"banner\"],body>header{view-transition-name:ssg-header}\
191footer[role=\"contentinfo\"],body>footer{view-transition-name:ssg-footer}\
192main{view-transition-name:ssg-main}\
193@media (prefers-reduced-motion: reduce){::view-transition-group(*),\
194::view-transition-old(*),::view-transition-new(*){animation:none!important}}\
195</style>";
196
197/// The injected client script. Kept ≤ 5 KB — verified by a unit test.
198///
199/// Intentionally hand-written (not minified) for readability and
200/// auditability. Browser support note: View Transitions API is
201/// chromium-stable since 111 and Safari-stable since 18 (2024-09).
202/// Firefox: in nightly behind a flag as of 2026-06 — falls back to
203/// full-page navigation gracefully (AC2).
204pub const VIEW_TRANSITIONS_JS: &str = r#"// SSG View Transitions client — issue #547
205// Same-origin nav interception + lazy hydration coordination.
206(() => {
207  const NS = 'ssg-transitions';
208  if (window[NS]) return; // idempotent
209  window[NS] = true;
210
211  const supportsVT = typeof document.startViewTransition === 'function';
212
213  // --- Same-origin click interception -----------------------------------
214  function shouldIntercept(ev, link) {
215    if (ev.defaultPrevented) return false;
216    if (ev.button !== 0) return false;
217    if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return false;
218    if (!link || !link.href) return false;
219    if (link.target && link.target !== '_self') return false;
220    if (link.hasAttribute('download')) return false;
221    if (link.dataset && link.dataset.noTransition !== undefined) return false;
222    const url = new URL(link.href, location.href);
223    if (url.origin !== location.origin) return false; // AC3
224    if (url.pathname === location.pathname && url.search === location.search) {
225      return false; // pure-hash nav — let the browser handle it
226    }
227    return true;
228  }
229
230  async function fetchPage(url) {
231    const res = await fetch(url, { credentials: 'same-origin' });
232    if (!res.ok) throw new Error('HTTP ' + res.status);
233    const text = await res.text();
234    return new DOMParser().parseFromString(text, 'text/html');
235  }
236
237  function swap(doc) {
238    // Swap <main> and update <title>. Header/footer stay in place
239    // (they're named as persistent transition roots via CSS).
240    const nextMain = doc.querySelector('main');
241    const curMain = document.querySelector('main');
242    if (nextMain && curMain) {
243      // Tell outgoing islands to detach. Web Components' own
244      // disconnectedCallback also runs after replaceWith, but firing
245      // an explicit event lets userland clean up too (AC5).
246      curMain.querySelectorAll('ssg-island').forEach((el) => {
247        try { el.dispatchEvent(new CustomEvent('ssg:detach')); } catch (e) {}
248        if (typeof el.detach === 'function') {
249          try { el.detach(); } catch (e) {}
250        }
251      });
252      curMain.replaceWith(nextMain);
253    }
254    if (doc.title) document.title = doc.title;
255
256    // Re-fire DOMContentLoaded-equivalent so other listeners
257    // (analytics, lazy-load shims) can rebind on the new page.
258    document.dispatchEvent(
259      new CustomEvent('ssg:after-swap', { detail: { url: location.href } })
260    );
261  }
262
263  async function navigate(url, push) {
264    try {
265      const doc = await fetchPage(url);
266      const run = () => swap(doc);
267      if (supportsVT) {
268        // startViewTransition returns a ViewTransition handle.
269        // Awaiting `.finished` lets us catch animation errors.
270        const t = document.startViewTransition(run);
271        if (t && t.finished) {
272          t.finished.catch(() => {}); // swallow user-aborted cancels
273        }
274      } else {
275        run(); // AC2 — graceful fallback (no animation)
276      }
277      if (push) history.pushState({ ssgvt: 1 }, '', url);
278      // Scroll to top for new navigations (mirrors browser default).
279      window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
280    } catch (err) {
281      // Network / parse failure: fall back to a hard navigation so
282      // the user still gets to the destination.
283      location.href = url;
284    }
285  }
286
287  document.addEventListener('click', (ev) => {
288    const link = ev.target && ev.target.closest && ev.target.closest('a[href]');
289    if (!shouldIntercept(ev, link)) return;
290    ev.preventDefault();
291    navigate(link.href, true);
292  });
293
294  window.addEventListener('popstate', (ev) => {
295    // Only handle popstates we originated — avoid stealing native
296    // anchor-only navigations the browser still owns.
297    if (ev.state && ev.state.ssgvt) navigate(location.href, false);
298  });
299
300  // --- HMR coordination (dev only) --------------------------------------
301  // The livereload client (src/server/livereload.rs) consults this
302  // flag before calling location.reload(). When transitions are
303  // enabled, structural reloads are wrapped in startViewTransition so
304  // the cross-fade is smooth even for full reloads (AC7).
305  window.__ssgTransitionsReload = function (reload) {
306    if (supportsVT) {
307      try {
308        document.startViewTransition(() => reload());
309        return;
310      } catch (e) {}
311    }
312    reload();
313  };
314})();
315"#;
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use tempfile::tempdir;
321
322    fn ctx_for(site: &Path) -> PluginContext {
323        PluginContext::new(
324            Path::new("/tmp/c"),
325            Path::new("/tmp/b"),
326            site,
327            Path::new("/tmp/t"),
328        )
329    }
330
331    #[test]
332    fn plugin_name_is_stable() {
333        assert_eq!(ViewTransitionsPlugin::new().name(), "view-transitions");
334    }
335
336    #[test]
337    fn plugin_has_transform() {
338        assert!(ViewTransitionsPlugin::new().has_transform());
339    }
340
341    #[test]
342    fn script_is_within_budget() {
343        // Hard budget — bumping this requires updating the issue AC.
344        // (Plain assert: lazily-formatted message args would be
345        // uncovered regions.)
346        assert!(VIEW_TRANSITIONS_JS.len() <= MAX_SCRIPT_BYTES);
347    }
348
349    #[test]
350    fn script_includes_supports_check() {
351        // AC2: must detect support and fall back.
352        assert!(VIEW_TRANSITIONS_JS.contains("startViewTransition"));
353        assert!(VIEW_TRANSITIONS_JS.contains("supportsVT"));
354    }
355
356    #[test]
357    fn script_includes_cross_origin_guard() {
358        // AC3: same-origin only.
359        assert!(VIEW_TRANSITIONS_JS.contains("url.origin !== location.origin"));
360    }
361
362    #[test]
363    fn script_includes_modified_click_guard() {
364        // No interception for cmd/ctrl/middle-click etc.
365        assert!(VIEW_TRANSITIONS_JS.contains("metaKey"));
366        assert!(VIEW_TRANSITIONS_JS.contains("ctrlKey"));
367    }
368
369    #[test]
370    fn script_dispatches_lifecycle_event() {
371        // AC5: islands on outgoing pages get a chance to detach.
372        assert!(VIEW_TRANSITIONS_JS.contains("ssg:detach"));
373        assert!(VIEW_TRANSITIONS_JS.contains("ssg:after-swap"));
374    }
375
376    #[test]
377    fn script_exposes_hmr_hook() {
378        // AC7: livereload calls window.__ssgTransitionsReload(reload).
379        assert!(VIEW_TRANSITIONS_JS.contains("__ssgTransitionsReload"));
380    }
381
382    #[test]
383    fn style_names_persistent_roots() {
384        // AC6: header + footer are persistent.
385        assert!(INLINE_STYLE.contains("ssg-header"));
386        assert!(INLINE_STYLE.contains("ssg-footer"));
387    }
388
389    #[test]
390    fn style_honours_prefers_reduced_motion() {
391        // Accessibility: animations must not fire when the user opts out.
392        assert!(INLINE_STYLE.contains("prefers-reduced-motion"));
393        assert!(INLINE_STYLE.contains("animation:none"));
394    }
395
396    #[test]
397    fn transform_adds_script_and_style() {
398        let html = "<html><head><title>x</title></head><body><main>x</main></body></html>";
399        let ctx = ctx_for(Path::new("/tmp/s"));
400        let out = ViewTransitionsPlugin::new()
401            .transform_html(html, Path::new("/tmp/x.html"), &ctx)
402            .unwrap();
403        assert!(out.contains(SCRIPT_URL));
404        assert!(out.contains(INJECT_MARKER));
405        assert!(out.contains("data-ssg-transitions-style"));
406    }
407
408    #[test]
409    fn transform_is_idempotent() {
410        let html = "<html><head></head><body></body></html>";
411        let ctx = ctx_for(Path::new("/tmp/s"));
412        let plugin = ViewTransitionsPlugin::new();
413        let once = plugin
414            .transform_html(html, Path::new("/tmp/x.html"), &ctx)
415            .unwrap();
416        let twice = plugin
417            .transform_html(&once, Path::new("/tmp/x.html"), &ctx)
418            .unwrap();
419        assert_eq!(once, twice);
420        assert_eq!(twice.matches(SCRIPT_URL).count(), 1);
421    }
422
423    #[test]
424    fn transform_skips_fragment_html() {
425        // A partial like a sitemap fragment without <body> shouldn't
426        // get the script injected.
427        let html = "<div><p>partial</p></div>";
428        let ctx = ctx_for(Path::new("/tmp/s"));
429        let out = ViewTransitionsPlugin::new()
430            .transform_html(html, Path::new("/tmp/x.html"), &ctx)
431            .unwrap();
432        assert_eq!(out, html);
433    }
434
435    #[test]
436    fn transform_injects_style_into_head() {
437        let html = "<html><head><meta charset=\"utf-8\"></head><body><main></main></body></html>";
438        let ctx = ctx_for(Path::new("/tmp/s"));
439        let out = ViewTransitionsPlugin::new()
440            .transform_html(html, Path::new("/tmp/x.html"), &ctx)
441            .unwrap();
442        // Style block must land inside <head>.
443        let head_end = out.find("</head>").unwrap();
444        let style_pos = out.find("data-ssg-transitions-style").unwrap();
445        assert!(style_pos < head_end);
446    }
447
448    #[test]
449    fn transform_injects_script_before_body_end() {
450        let html = "<html><head></head><body><main>x</main></body></html>";
451        let ctx = ctx_for(Path::new("/tmp/s"));
452        let out = ViewTransitionsPlugin::new()
453            .transform_html(html, Path::new("/tmp/x.html"), &ctx)
454            .unwrap();
455        let body_end = out.rfind("</body>").unwrap();
456        let script_pos = out.find(SCRIPT_URL).unwrap();
457        assert!(script_pos < body_end);
458    }
459
460    #[test]
461    fn transform_handles_missing_head_gracefully() {
462        // Some emitters produce <html><body> without an explicit <head>.
463        let html = "<html><body><main>x</main></body></html>";
464        let ctx = ctx_for(Path::new("/tmp/s"));
465        let out = ViewTransitionsPlugin::new()
466            .transform_html(html, Path::new("/tmp/x.html"), &ctx)
467            .unwrap();
468        // Script still injected even when style cannot be placed.
469        assert!(out.contains(SCRIPT_URL));
470    }
471
472    #[test]
473    fn after_compile_writes_script_file() {
474        let dir = tempdir().unwrap();
475        let site = dir.path().join("site");
476        fs::create_dir_all(&site).unwrap();
477
478        let ctx = ctx_for(&site);
479        ViewTransitionsPlugin::new().after_compile(&ctx).unwrap();
480
481        let path = site.join(SCRIPT_DIR).join(SCRIPT_FILENAME);
482        assert!(path.exists());
483        let contents = fs::read_to_string(&path).unwrap();
484        assert!(contents.contains("startViewTransition"));
485    }
486
487    #[test]
488    fn after_compile_is_noop_when_site_missing() {
489        let ctx = ctx_for(Path::new("/nonexistent/site/dir/xyz"));
490        assert!(ViewTransitionsPlugin::new().after_compile(&ctx).is_ok());
491    }
492
493    #[test]
494    fn after_compile_respects_dry_run() {
495        let dir = tempdir().unwrap();
496        let site = dir.path().join("site");
497        fs::create_dir_all(&site).unwrap();
498
499        let ctx = ctx_for(&site).with_dry_run(true);
500        ViewTransitionsPlugin::new().after_compile(&ctx).unwrap();
501
502        assert!(!site.join(SCRIPT_DIR).exists());
503    }
504
505    #[test]
506    fn enabled_reads_config_flag() {
507        let mut cfg = SsgConfig::builder()
508            .site_name("t".into())
509            .base_url("http://example.com".into())
510            .build()
511            .unwrap();
512        assert!(!ViewTransitionsPlugin::enabled(&cfg));
513        cfg.transitions = true;
514        assert!(ViewTransitionsPlugin::enabled(&cfg));
515    }
516
517    #[test]
518    fn script_includes_history_push() {
519        // Smooth-nav must update history so back/forward work.
520        assert!(VIEW_TRANSITIONS_JS.contains("history.pushState"));
521        assert!(VIEW_TRANSITIONS_JS.contains("popstate"));
522    }
523
524    #[test]
525    fn script_includes_download_and_target_guards() {
526        // Don't intercept download or target=_blank links.
527        assert!(VIEW_TRANSITIONS_JS.contains("download"));
528        assert!(VIEW_TRANSITIONS_JS.contains("_self"));
529    }
530
531    #[test]
532    fn script_includes_opt_out_attribute() {
533        // Explicit per-link opt-out via data-no-transition.
534        assert!(VIEW_TRANSITIONS_JS.contains("noTransition"));
535    }
536
537    #[test]
538    fn script_falls_back_on_fetch_failure() {
539        // 5xx / network errors should still get the user to the page.
540        assert!(VIEW_TRANSITIONS_JS.contains("location.href = url"));
541    }
542
543    #[test]
544    fn transform_appends_script_when_html_close_but_no_body_close() {
545        // `</html>` passes the fragment guard, but with no `</body>`
546        // the script is appended at the end.
547        let plugin = ViewTransitionsPlugin::new();
548        let dir = tempdir().unwrap();
549        let html = "<html><head></head>x</html>";
550        let out = plugin
551            .transform_html(html, Path::new("i.html"), &ctx_for(dir.path()))
552            .unwrap();
553        assert!(out.ends_with("</script>\n"));
554    }
555
556    #[test]
557    fn after_compile_fails_when_script_dir_squatted_by_file() {
558        let dir = tempdir().unwrap();
559        let site = dir.path().join("site");
560        fs::create_dir_all(&site).unwrap();
561        fs::write(site.join(SCRIPT_DIR), "not a dir").unwrap();
562        let err = ViewTransitionsPlugin::new()
563            .after_compile(&ctx_for(&site))
564            .unwrap_err();
565        assert!(!format!("{err}").is_empty());
566    }
567
568    #[test]
569    fn after_compile_fails_when_script_file_squatted_by_dir() {
570        let dir = tempdir().unwrap();
571        let site = dir.path().join("site");
572        fs::create_dir_all(site.join(SCRIPT_DIR).join(SCRIPT_FILENAME))
573            .unwrap();
574        let err = ViewTransitionsPlugin::new()
575            .after_compile(&ctx_for(&site))
576            .unwrap_err();
577        assert!(!format!("{err}").is_empty());
578    }
579}