use crate::cmd::SsgConfig;
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use std::{fs, path::Path};
const SCRIPT_DIR: &str = "_transitions";
const SCRIPT_URL: &str = "/_transitions/ssg-transitions.js";
const SCRIPT_FILENAME: &str = "ssg-transitions.js";
const INJECT_MARKER: &str = "data-ssg-transitions";
#[cfg(test)]
const MAX_SCRIPT_BYTES: usize = 5 * 1024;
#[derive(Debug, Clone, Copy, Default)]
pub struct ViewTransitionsPlugin;
impl ViewTransitionsPlugin {
#[must_use]
pub const fn new() -> Self {
Self
}
#[must_use]
pub const fn enabled(cfg: &SsgConfig) -> bool {
cfg.transitions
}
}
impl Plugin for ViewTransitionsPlugin {
fn name(&self) -> &'static str {
"view-transitions"
}
fn has_transform(&self) -> bool {
true
}
fn transform_html(
&self,
html: &str,
_path: &Path,
_ctx: &PluginContext,
) -> Result<String, SsgError> {
if html.contains(INJECT_MARKER) {
return Ok(html.to_string());
}
if !html.contains("</body>") && !html.contains("</html>") {
return Ok(html.to_string());
}
let head_block = format!(" {INLINE_STYLE}\n");
let script_tag = format!(
" <script type=\"module\" defer {INJECT_MARKER} src=\"{SCRIPT_URL}\"></script>\n"
);
let with_style = if let Some(pos) = html.find("</head>") {
format!("{}{head_block}{}", &html[..pos], &html[pos..])
} else {
html.to_string()
};
let with_script = if let Some(pos) = with_style.rfind("</body>") {
format!("{}{script_tag}{}", &with_style[..pos], &with_style[pos..])
} else {
format!("{with_style}{script_tag}")
};
Ok(with_script)
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
if !ctx.site_dir.exists() {
return Ok(());
}
if ctx.dry_run {
return Ok(());
}
let dir = ctx.site_dir.join(SCRIPT_DIR);
fs::create_dir_all(&dir).with_path(&dir)?;
let path = dir.join(SCRIPT_FILENAME);
fs::write(&path, VIEW_TRANSITIONS_JS).with_path(&path)?;
log::info!(
"[view-transitions] wrote client script ({} bytes)",
VIEW_TRANSITIONS_JS.len(),
);
Ok(())
}
}
const INLINE_STYLE: &str = "<style data-ssg-transitions-style>\
header[role=\"banner\"],body>header{view-transition-name:ssg-header}\
footer[role=\"contentinfo\"],body>footer{view-transition-name:ssg-footer}\
main{view-transition-name:ssg-main}\
@media (prefers-reduced-motion: reduce){::view-transition-group(*),\
::view-transition-old(*),::view-transition-new(*){animation:none!important}}\
</style>";
pub const VIEW_TRANSITIONS_JS: &str = r#"// SSG View Transitions client — issue #547
// Same-origin nav interception + lazy hydration coordination.
(() => {
const NS = 'ssg-transitions';
if (window[NS]) return; // idempotent
window[NS] = true;
const supportsVT = typeof document.startViewTransition === 'function';
// --- Same-origin click interception -----------------------------------
function shouldIntercept(ev, link) {
if (ev.defaultPrevented) return false;
if (ev.button !== 0) return false;
if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return false;
if (!link || !link.href) return false;
if (link.target && link.target !== '_self') return false;
if (link.hasAttribute('download')) return false;
if (link.dataset && link.dataset.noTransition !== undefined) return false;
const url = new URL(link.href, location.href);
if (url.origin !== location.origin) return false; // AC3
if (url.pathname === location.pathname && url.search === location.search) {
return false; // pure-hash nav — let the browser handle it
}
return true;
}
async function fetchPage(url) {
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const text = await res.text();
return new DOMParser().parseFromString(text, 'text/html');
}
function swap(doc) {
// Swap <main> and update <title>. Header/footer stay in place
// (they're named as persistent transition roots via CSS).
const nextMain = doc.querySelector('main');
const curMain = document.querySelector('main');
if (nextMain && curMain) {
// Tell outgoing islands to detach. Web Components' own
// disconnectedCallback also runs after replaceWith, but firing
// an explicit event lets userland clean up too (AC5).
curMain.querySelectorAll('ssg-island').forEach((el) => {
try { el.dispatchEvent(new CustomEvent('ssg:detach')); } catch (e) {}
if (typeof el.detach === 'function') {
try { el.detach(); } catch (e) {}
}
});
curMain.replaceWith(nextMain);
}
if (doc.title) document.title = doc.title;
// Re-fire DOMContentLoaded-equivalent so other listeners
// (analytics, lazy-load shims) can rebind on the new page.
document.dispatchEvent(
new CustomEvent('ssg:after-swap', { detail: { url: location.href } })
);
}
async function navigate(url, push) {
try {
const doc = await fetchPage(url);
const run = () => swap(doc);
if (supportsVT) {
// startViewTransition returns a ViewTransition handle.
// Awaiting `.finished` lets us catch animation errors.
const t = document.startViewTransition(run);
if (t && t.finished) {
t.finished.catch(() => {}); // swallow user-aborted cancels
}
} else {
run(); // AC2 — graceful fallback (no animation)
}
if (push) history.pushState({ ssgvt: 1 }, '', url);
// Scroll to top for new navigations (mirrors browser default).
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
} catch (err) {
// Network / parse failure: fall back to a hard navigation so
// the user still gets to the destination.
location.href = url;
}
}
document.addEventListener('click', (ev) => {
const link = ev.target && ev.target.closest && ev.target.closest('a[href]');
if (!shouldIntercept(ev, link)) return;
ev.preventDefault();
navigate(link.href, true);
});
window.addEventListener('popstate', (ev) => {
// Only handle popstates we originated — avoid stealing native
// anchor-only navigations the browser still owns.
if (ev.state && ev.state.ssgvt) navigate(location.href, false);
});
// --- HMR coordination (dev only) --------------------------------------
// The livereload client (src/server/livereload.rs) consults this
// flag before calling location.reload(). When transitions are
// enabled, structural reloads are wrapped in startViewTransition so
// the cross-fade is smooth even for full reloads (AC7).
window.__ssgTransitionsReload = function (reload) {
if (supportsVT) {
try {
document.startViewTransition(() => reload());
return;
} catch (e) {}
}
reload();
};
})();
"#;
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
fn ctx_for(site: &Path) -> PluginContext {
PluginContext::new(
Path::new("/tmp/c"),
Path::new("/tmp/b"),
site,
Path::new("/tmp/t"),
)
}
#[test]
fn plugin_name_is_stable() {
assert_eq!(ViewTransitionsPlugin::new().name(), "view-transitions");
}
#[test]
fn plugin_has_transform() {
assert!(ViewTransitionsPlugin::new().has_transform());
}
#[test]
fn script_is_within_budget() {
assert!(VIEW_TRANSITIONS_JS.len() <= MAX_SCRIPT_BYTES);
}
#[test]
fn script_includes_supports_check() {
assert!(VIEW_TRANSITIONS_JS.contains("startViewTransition"));
assert!(VIEW_TRANSITIONS_JS.contains("supportsVT"));
}
#[test]
fn script_includes_cross_origin_guard() {
assert!(VIEW_TRANSITIONS_JS.contains("url.origin !== location.origin"));
}
#[test]
fn script_includes_modified_click_guard() {
assert!(VIEW_TRANSITIONS_JS.contains("metaKey"));
assert!(VIEW_TRANSITIONS_JS.contains("ctrlKey"));
}
#[test]
fn script_dispatches_lifecycle_event() {
assert!(VIEW_TRANSITIONS_JS.contains("ssg:detach"));
assert!(VIEW_TRANSITIONS_JS.contains("ssg:after-swap"));
}
#[test]
fn script_exposes_hmr_hook() {
assert!(VIEW_TRANSITIONS_JS.contains("__ssgTransitionsReload"));
}
#[test]
fn style_names_persistent_roots() {
assert!(INLINE_STYLE.contains("ssg-header"));
assert!(INLINE_STYLE.contains("ssg-footer"));
}
#[test]
fn style_honours_prefers_reduced_motion() {
assert!(INLINE_STYLE.contains("prefers-reduced-motion"));
assert!(INLINE_STYLE.contains("animation:none"));
}
#[test]
fn transform_adds_script_and_style() {
let html = "<html><head><title>x</title></head><body><main>x</main></body></html>";
let ctx = ctx_for(Path::new("/tmp/s"));
let out = ViewTransitionsPlugin::new()
.transform_html(html, Path::new("/tmp/x.html"), &ctx)
.unwrap();
assert!(out.contains(SCRIPT_URL));
assert!(out.contains(INJECT_MARKER));
assert!(out.contains("data-ssg-transitions-style"));
}
#[test]
fn transform_is_idempotent() {
let html = "<html><head></head><body></body></html>";
let ctx = ctx_for(Path::new("/tmp/s"));
let plugin = ViewTransitionsPlugin::new();
let once = plugin
.transform_html(html, Path::new("/tmp/x.html"), &ctx)
.unwrap();
let twice = plugin
.transform_html(&once, Path::new("/tmp/x.html"), &ctx)
.unwrap();
assert_eq!(once, twice);
assert_eq!(twice.matches(SCRIPT_URL).count(), 1);
}
#[test]
fn transform_skips_fragment_html() {
let html = "<div><p>partial</p></div>";
let ctx = ctx_for(Path::new("/tmp/s"));
let out = ViewTransitionsPlugin::new()
.transform_html(html, Path::new("/tmp/x.html"), &ctx)
.unwrap();
assert_eq!(out, html);
}
#[test]
fn transform_injects_style_into_head() {
let html = "<html><head><meta charset=\"utf-8\"></head><body><main></main></body></html>";
let ctx = ctx_for(Path::new("/tmp/s"));
let out = ViewTransitionsPlugin::new()
.transform_html(html, Path::new("/tmp/x.html"), &ctx)
.unwrap();
let head_end = out.find("</head>").unwrap();
let style_pos = out.find("data-ssg-transitions-style").unwrap();
assert!(style_pos < head_end);
}
#[test]
fn transform_injects_script_before_body_end() {
let html = "<html><head></head><body><main>x</main></body></html>";
let ctx = ctx_for(Path::new("/tmp/s"));
let out = ViewTransitionsPlugin::new()
.transform_html(html, Path::new("/tmp/x.html"), &ctx)
.unwrap();
let body_end = out.rfind("</body>").unwrap();
let script_pos = out.find(SCRIPT_URL).unwrap();
assert!(script_pos < body_end);
}
#[test]
fn transform_handles_missing_head_gracefully() {
let html = "<html><body><main>x</main></body></html>";
let ctx = ctx_for(Path::new("/tmp/s"));
let out = ViewTransitionsPlugin::new()
.transform_html(html, Path::new("/tmp/x.html"), &ctx)
.unwrap();
assert!(out.contains(SCRIPT_URL));
}
#[test]
fn after_compile_writes_script_file() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let ctx = ctx_for(&site);
ViewTransitionsPlugin::new().after_compile(&ctx).unwrap();
let path = site.join(SCRIPT_DIR).join(SCRIPT_FILENAME);
assert!(path.exists());
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("startViewTransition"));
}
#[test]
fn after_compile_is_noop_when_site_missing() {
let ctx = ctx_for(Path::new("/nonexistent/site/dir/xyz"));
assert!(ViewTransitionsPlugin::new().after_compile(&ctx).is_ok());
}
#[test]
fn after_compile_respects_dry_run() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
let ctx = ctx_for(&site).with_dry_run(true);
ViewTransitionsPlugin::new().after_compile(&ctx).unwrap();
assert!(!site.join(SCRIPT_DIR).exists());
}
#[test]
fn enabled_reads_config_flag() {
let mut cfg = SsgConfig::builder()
.site_name("t".into())
.base_url("http://example.com".into())
.build()
.unwrap();
assert!(!ViewTransitionsPlugin::enabled(&cfg));
cfg.transitions = true;
assert!(ViewTransitionsPlugin::enabled(&cfg));
}
#[test]
fn script_includes_history_push() {
assert!(VIEW_TRANSITIONS_JS.contains("history.pushState"));
assert!(VIEW_TRANSITIONS_JS.contains("popstate"));
}
#[test]
fn script_includes_download_and_target_guards() {
assert!(VIEW_TRANSITIONS_JS.contains("download"));
assert!(VIEW_TRANSITIONS_JS.contains("_self"));
}
#[test]
fn script_includes_opt_out_attribute() {
assert!(VIEW_TRANSITIONS_JS.contains("noTransition"));
}
#[test]
fn script_falls_back_on_fetch_failure() {
assert!(VIEW_TRANSITIONS_JS.contains("location.href = url"));
}
#[test]
fn transform_appends_script_when_html_close_but_no_body_close() {
let plugin = ViewTransitionsPlugin::new();
let dir = tempdir().unwrap();
let html = "<html><head></head>x</html>";
let out = plugin
.transform_html(html, Path::new("i.html"), &ctx_for(dir.path()))
.unwrap();
assert!(out.ends_with("</script>\n"));
}
#[test]
fn after_compile_fails_when_script_dir_squatted_by_file() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join(SCRIPT_DIR), "not a dir").unwrap();
let err = ViewTransitionsPlugin::new()
.after_compile(&ctx_for(&site))
.unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
fn after_compile_fails_when_script_file_squatted_by_dir() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(site.join(SCRIPT_DIR).join(SCRIPT_FILENAME))
.unwrap();
let err = ViewTransitionsPlugin::new()
.after_compile(&ctx_for(&site))
.unwrap_err();
assert!(!format!("{err}").is_empty());
}
}