ssg/plugins/
view_transitions.rs1use 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
50const SCRIPT_DIR: &str = "_transitions";
52
53const SCRIPT_URL: &str = "/_transitions/ssg-transitions.js";
55
56const SCRIPT_FILENAME: &str = "ssg-transitions.js";
58
59const INJECT_MARKER: &str = "data-ssg-transitions";
63
64#[cfg(test)]
67const MAX_SCRIPT_BYTES: usize = 5 * 1024;
68
69#[derive(Debug, Clone, Copy, Default)]
82pub struct ViewTransitionsPlugin;
83
84impl ViewTransitionsPlugin {
85 #[must_use]
94 pub const fn new() -> Self {
95 Self
96 }
97
98 #[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 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 let with_style =
154 crate::util::head_dom::inject_before_head_close(html, &head_block);
155
156 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
185const 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
197pub 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 assert!(VIEW_TRANSITIONS_JS.len() <= MAX_SCRIPT_BYTES);
347 }
348
349 #[test]
350 fn script_includes_supports_check() {
351 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 assert!(VIEW_TRANSITIONS_JS.contains("url.origin !== location.origin"));
360 }
361
362 #[test]
363 fn script_includes_modified_click_guard() {
364 assert!(VIEW_TRANSITIONS_JS.contains("metaKey"));
366 assert!(VIEW_TRANSITIONS_JS.contains("ctrlKey"));
367 }
368
369 #[test]
370 fn script_dispatches_lifecycle_event() {
371 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 assert!(VIEW_TRANSITIONS_JS.contains("__ssgTransitionsReload"));
380 }
381
382 #[test]
383 fn style_names_persistent_roots() {
384 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 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 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 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 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 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 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 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 assert!(VIEW_TRANSITIONS_JS.contains("noTransition"));
535 }
536
537 #[test]
538 fn script_falls_back_on_fetch_failure() {
539 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 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}