Rust library to generate static websites

feat(prefetch): Add support for prerendering with spectulation rules (#90)

* feat: progress

* fix: tests

* fix: tests

* fix: urls

* fix: cleanup workspace

* feat: bunch of things

* docs: docs

* chore: changeset

* ci: run e2e in ci

* fix: some things

authored by

Erika and committed by
GitHub
a5b49ada d769d5c3

+1119 -28
+32
.github/workflows/ci.yaml
··· 77 - name: Build 78 run: cargo build 79 80 test: 81 name: Test 82 runs-on: ubuntu-latest
··· 77 - name: Build 78 run: cargo build 79 80 + e2e: 81 + name: Test (E2E) 82 + runs-on: ubuntu-latest 83 + steps: 84 + - name: Checkout 85 + uses: actions/checkout@v4 86 + 87 + - name: Setup Rust 88 + uses: moonrepo/setup-rust@v1 89 + 90 + - name: Setup pnpm 91 + uses: pnpm/action-setup@v4 92 + 93 + - name: Setup Node.js 94 + uses: actions/setup-node@v4 95 + with: 96 + node-version: latest 97 + cache: 'pnpm' 98 + 99 + - name: Install dependencies 100 + run: pnpm install 101 + 102 + - name: Build JS dependencies 103 + run: cargo xtask build-js 104 + 105 + - name: Build 106 + run: cargo build 107 + 108 + - name: Test 109 + working-directory: ./e2e 110 + run: pnpm playwright install chromium && pnpm test 111 + 112 test: 113 name: Test 114 runs-on: ubuntu-latest
+5
.sampo/changesets/bold-baroness-joukahainen.md
···
··· 1 + --- 2 + cargo/maudit-cli: minor 3 + --- 4 + 5 + Adds support for passing a port to the dev using the `--port` option.
+5
.sampo/changesets/gallant-wavetamer-nyyrikki.md
···
··· 1 + --- 2 + cargo/maudit: minor 3 + --- 4 + 5 + Adds support for prefetching / prerendering pages using the Speculation Rules API
+5
.sampo/changesets/noble-earl-ilmarinen.md
···
··· 1 + --- 2 + cargo/maudit-cli: patch 3 + --- 4 + 5 + Fixed an issue where Maudit would not properly liberate the port when shutting down
+8
Cargo.lock
··· 3826 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3827 3828 [[package]] 3829 name = "proc-macro-crate" 3830 version = "3.4.0" 3831 source = "registry+https://github.com/rust-lang/crates.io-index"
··· 3826 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3827 3828 [[package]] 3829 + name = "prefetch-prerender" 3830 + version = "0.1.0" 3831 + dependencies = [ 3832 + "maud", 3833 + "maudit", 3834 + ] 3835 + 3836 + [[package]] 3837 name = "proc-macro-crate" 3838 version = "3.4.0" 3839 source = "registry+https://github.com/rust-lang/crates.io-index"
+1 -1
Cargo.toml
··· 1 [workspace] 2 - members = ["crates/*", "benchmarks/*", "examples/*", "website", "xtask"] 3 resolver = "3" 4 5 [workspace.dependencies]
··· 1 [workspace] 2 + members = ["crates/*", "benchmarks/*", "examples/*", "website", "xtask", "e2e/fixtures/*"] 3 resolver = "3" 4 5 [workspace.dependencies]
+1
benchmarks/md-benchmark/src/lib.rs
··· 15 // is spent in bundling, including the script in pages, etc. instead of that. It's still neat to see how much overhead prefetching adds, 16 // but not really in this benchmark. 17 strategy: PrefetchStrategy::None, 18 }, 19 ..Default::default() 20 },
··· 15 // is spent in bundling, including the script in pages, etc. instead of that. It's still neat to see how much overhead prefetching adds, 16 // but not really in this benchmark. 17 strategy: PrefetchStrategy::None, 18 + ..Default::default() 19 }, 20 ..Default::default() 21 },
+1
benchmarks/overhead/src/lib.rs
··· 10 // This benchmark is really about testing Maudit's overhead, if we enable prefetching then a lot of time 11 // is spent in bundling, including the script in pages, etc. instead of Maudit itself. 12 strategy: PrefetchStrategy::None, 13 }, 14 ..Default::default() 15 },
··· 10 // This benchmark is really about testing Maudit's overhead, if we enable prefetching then a lot of time 11 // is spent in bundling, including the script in pages, etc. instead of Maudit itself. 12 strategy: PrefetchStrategy::None, 13 + ..Default::default() 14 }, 15 ..Default::default() 16 },
+2
crates/maudit-cli/src/consts.rs
···
··· 1 + /// Default port used by the development web server. 2 + pub const PORT: u16 = 1864;
+44 -10
crates/maudit-cli/src/dev.rs
··· 12 use server::WebSocketMessage; 13 use std::{fs, path::Path}; 14 use tokio::{ 15 sync::{broadcast, mpsc::channel}, 16 task::JoinHandle, 17 }; ··· 19 20 use crate::dev::build::BuildManager; 21 22 - pub async fn start_dev_env(cwd: &str, host: bool) -> Result<(), Box<dyn std::error::Error>> { 23 let start_time = Instant::now(); 24 info!(name: "dev", "Preparing dev environment…"); 25 ··· 74 start_time, 75 sender_websocket.clone(), 76 host, 77 None, 78 build_manager.current_status(), 79 ))); ··· 147 start_time, 148 sender_websocket_watcher.clone(), 149 host, 150 None, 151 build_manager_watcher.current_status(), 152 ))); ··· 196 } 197 }); 198 199 - // Wait for either the web server or the file watcher to finish 200 - if let Some(web_server) = web_server_thread { 201 - tokio::select! { 202 - _ = web_server => {}, 203 - _ = file_watcher_task => {}, 204 } 205 - } else { 206 - // No web server started yet, just wait for file watcher 207 - // If it started the web server, it'll also close itself if the web server ends 208 - file_watcher_task.await.unwrap(); 209 } 210 Ok(()) 211 } ··· 248 249 true 250 }
··· 12 use server::WebSocketMessage; 13 use std::{fs, path::Path}; 14 use tokio::{ 15 + signal, 16 sync::{broadcast, mpsc::channel}, 17 task::JoinHandle, 18 }; ··· 20 21 use crate::dev::build::BuildManager; 22 23 + pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> { 24 let start_time = Instant::now(); 25 info!(name: "dev", "Preparing dev environment…"); 26 ··· 75 start_time, 76 sender_websocket.clone(), 77 host, 78 + port, 79 None, 80 build_manager.current_status(), 81 ))); ··· 149 start_time, 150 sender_websocket_watcher.clone(), 151 host, 152 + port, 153 None, 154 build_manager_watcher.current_status(), 155 ))); ··· 199 } 200 }); 201 202 + // Wait for either the web server, file watcher, or shutdown signal 203 + tokio::select! { 204 + _ = shutdown_signal() => { 205 + info!(name: "dev", "Shutting down dev environment..."); 206 } 207 + _ = async { 208 + if let Some(web_server) = web_server_thread { 209 + tokio::select! { 210 + _ = web_server => {}, 211 + _ = file_watcher_task => {}, 212 + } 213 + } else { 214 + // No web server started yet, just wait for file watcher 215 + // If it started the web server, it'll also close itself if the web server ends 216 + file_watcher_task.await.unwrap(); 217 + } 218 + } => {} 219 } 220 Ok(()) 221 } ··· 258 259 true 260 } 261 + 262 + async fn shutdown_signal() { 263 + let ctrl_c = async { 264 + signal::ctrl_c() 265 + .await 266 + .expect("failed to install Ctrl+C handler"); 267 + }; 268 + 269 + #[cfg(unix)] 270 + let terminate = async { 271 + signal::unix::signal(signal::unix::SignalKind::terminate()) 272 + .expect("failed to install signal handler") 273 + .recv() 274 + .await; 275 + }; 276 + 277 + #[cfg(not(unix))] 278 + let terminate = std::future::pending::<()>(); 279 + 280 + tokio::select! { 281 + _ = ctrl_c => {}, 282 + _ = terminate => {}, 283 + } 284 + }
+7 -2
crates/maudit-cli/src/dev/server.rs
··· 29 use axum::extract::connect_info::ConnectInfo; 30 use futures::{SinkExt, stream::StreamExt}; 31 32 use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start}; 33 use axum::http::header; 34 use local_ip_address::local_ip; ··· 94 start_time: Instant, 95 tx: broadcast::Sender<WebSocketMessage>, 96 host: bool, 97 initial_error: Option<String>, 98 current_status: Arc<RwLock<Option<PersistentStatus>>>, 99 ) { ··· 131 } else { 132 IpAddr::from([127, 0, 0, 1]) 133 }; 134 - let port = find_open_port(&addr, 1864).await; 135 let socket = TcpSocket::new_v4().unwrap(); 136 let _ = socket.set_reuseaddr(true); 137 - let _ = socket.set_reuseport(true); 138 139 let socket_addr = SocketAddr::new(addr, port); 140 socket.bind(socket_addr).unwrap();
··· 29 use axum::extract::connect_info::ConnectInfo; 30 use futures::{SinkExt, stream::StreamExt}; 31 32 + use crate::consts::PORT; 33 use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start}; 34 use axum::http::header; 35 use local_ip_address::local_ip; ··· 95 start_time: Instant, 96 tx: broadcast::Sender<WebSocketMessage>, 97 host: bool, 98 + port: Option<u16>, 99 initial_error: Option<String>, 100 current_status: Arc<RwLock<Option<PersistentStatus>>>, 101 ) { ··· 133 } else { 134 IpAddr::from([127, 0, 0, 1]) 135 }; 136 + 137 + // Use provided port or default to the constant PORT 138 + let starting_port = port.unwrap_or(PORT); 139 + 140 + let port = find_open_port(&addr, starting_port).await; 141 let socket = TcpSocket::new_v4().unwrap(); 142 let _ = socket.set_reuseaddr(true); 143 144 let socket_addr = SocketAddr::new(addr, port); 145 socket.bind(socket_addr).unwrap();
+8 -2
crates/maudit-cli/src/main.rs
··· 3 mod init; 4 mod preview; 5 6 mod logging; 7 mod server_utils; 8 ··· 33 Dev { 34 #[clap(long)] 35 host: bool, 36 }, 37 /// Preview the project 38 Preview { ··· 67 68 let _ = start_preview_web_server(PathBuf::from("dist"), *host).await; 69 } 70 - Commands::Dev { host } => { 71 // TODO: cwd should be configurable, ex: --root <path> 72 - let _ = start_dev_env(".", *host).await; 73 } 74 } 75 }
··· 3 mod init; 4 mod preview; 5 6 + mod consts; 7 + 8 mod logging; 9 mod server_utils; 10 ··· 35 Dev { 36 #[clap(long)] 37 host: bool, 38 + 39 + /// Port to run the dev server on 40 + #[clap(long, short, default_value_t = crate::consts::PORT)] 41 + port: u16, 42 }, 43 /// Preview the project 44 Preview { ··· 73 74 let _ = start_preview_web_server(PathBuf::from("dist"), *host).await; 75 } 76 + Commands::Dev { host, port } => { 77 // TODO: cwd should be configurable, ex: --root <path> 78 + let _ = start_dev_env(".", *host, Some(*port)).await; 79 } 80 } 81 }
+2 -1
crates/maudit-cli/src/preview/server.rs
··· 16 trace::{DefaultMakeSpan, TraceLayer}, 17 }; 18 19 use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start}; 20 21 pub async fn start_preview_web_server(dist_dir: PathBuf, host: bool) { ··· 42 IpAddr::from([127, 0, 0, 1]) 43 }; 44 45 - let port = find_open_port(&addr, 1864).await; 46 let socket = TcpSocket::new_v4().unwrap(); 47 let _ = socket.set_reuseaddr(true); 48 let _ = socket.set_reuseport(true);
··· 16 trace::{DefaultMakeSpan, TraceLayer}, 17 }; 18 19 + use crate::consts::PORT; 20 use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start}; 21 22 pub async fn start_preview_web_server(dist_dir: PathBuf, host: bool) { ··· 43 IpAddr::from([127, 0, 0, 1]) 44 }; 45 46 + let port = find_open_port(&addr, PORT).await; 47 let socket = TcpSocket::new_v4().unwrap(); 48 let _ = socket.set_reuseaddr(true); 49 let _ = socket.set_reuseport(true);
+4 -5
crates/maudit-macros/src/lib.rs
··· 247 } 248 } 249 LocaleKind::Prefix(prefix) => { 250 - if args.path.is_none() { 251 - // Emit compile error if prefix is used without base path 252 quote! { 253 - compile_error!("Cannot use locale prefix without a base route path") 254 } 255 } else { 256 - let base_path = args.path.as_ref().unwrap(); 257 quote! { 258 - (#locale_name.to_string(), format!("{}{}", #prefix, #base_path)) 259 } 260 } 261 }
··· 247 } 248 } 249 LocaleKind::Prefix(prefix) => { 250 + if let Some(base_path) = args.path.as_ref() { 251 quote! { 252 + (#locale_name.to_string(), format!("{}{}", #prefix, #base_path)) 253 } 254 } else { 255 + // Emit compile error if prefix is used without base path 256 quote! { 257 + compile_error!("Cannot use locale prefix without a base route path") 258 } 259 } 260 }
+74 -2
crates/maudit/js/prefetch.ts
··· 2 3 interface PreloadConfig { 4 skipConnectionCheck?: boolean; 5 } 6 7 export function prefetch(url: string, config?: PreloadConfig) { ··· 14 } 15 16 const skipConnectionCheck = config?.skipConnectionCheck ?? false; 17 18 if (!canPrefetchUrl(urlObj, skipConnectionCheck)) { 19 return; 20 } 21 22 const linkElement = document.createElement("link"); 23 const supportsPrefetch = linkElement.relList?.supports?.("prefetch"); 24 25 if (supportsPrefetch) { 26 linkElement.rel = "prefetch"; 27 - linkElement.href = url; 28 document.head.appendChild(linkElement); 29 - preloadedUrls.add(urlObj.href); 30 } 31 } 32 ··· 51 52 return false; 53 }
··· 2 3 interface PreloadConfig { 4 skipConnectionCheck?: boolean; 5 + /** 6 + * Enable prerendering using Speculation Rules API if supported. 7 + * Falls back to prefetch if not supported. (default: false) 8 + */ 9 + prerender?: boolean; 10 + /** 11 + * Hint to the browser as to how eagerly it should prefetch/prerender. 12 + * Only works when browser supports Speculation Rules API. 13 + * (default: 'immediate') 14 + * 15 + * - 'immediate': Prefetch/prerender as soon as possible 16 + * - 'eager': Prefetch/prerender eagerly but not immediately 17 + * - 'moderate': Prefetch/prerender with moderate eagerness 18 + * - 'conservative': Prefetch/prerender conservatively 19 + */ 20 + eagerness?: "immediate" | "eager" | "moderate" | "conservative"; 21 } 22 23 export function prefetch(url: string, config?: PreloadConfig) { ··· 30 } 31 32 const skipConnectionCheck = config?.skipConnectionCheck ?? false; 33 + const shouldPrerender = config?.prerender ?? false; 34 + const eagerness = config?.eagerness ?? "immediate"; 35 36 if (!canPrefetchUrl(urlObj, skipConnectionCheck)) { 37 return; 38 } 39 40 + preloadedUrls.add(urlObj.href); 41 + 42 + // Calculate relative path once (pathname + search, no origin) 43 + const path = urlObj.pathname + urlObj.search; 44 + 45 + // Use Speculation Rules API when supported 46 + if (HTMLScriptElement.supports && HTMLScriptElement.supports("speculationrules")) { 47 + appendSpeculationRules(path, eagerness, shouldPrerender); 48 + return; 49 + } 50 + 51 + // Fallback to link prefetch for other browsers 52 const linkElement = document.createElement("link"); 53 const supportsPrefetch = linkElement.relList?.supports?.("prefetch"); 54 55 if (supportsPrefetch) { 56 linkElement.rel = "prefetch"; 57 + linkElement.href = path; 58 document.head.appendChild(linkElement); 59 } 60 } 61 ··· 80 81 return false; 82 } 83 + 84 + /** 85 + * Appends a <script type="speculationrules"> tag to prefetch or prerender the URL. 86 + * 87 + * Note: Each URL needs its own script element - modifying an existing 88 + * script won't trigger a new prerender/prefetch. 89 + * 90 + * @param path - The relative path (pathname + search) to prefetch/prerender 91 + * @param eagerness - How eagerly the browser should prefetch/prerender 92 + * @param prerender - Whether to include a prerender rule 93 + */ 94 + function appendSpeculationRules( 95 + path: string, 96 + eagerness: NonNullable<PreloadConfig["eagerness"]>, 97 + prerender: boolean, 98 + ) { 99 + const script = document.createElement("script"); 100 + script.type = "speculationrules"; 101 + 102 + // We always want the prefetch, even if prerendering as a fallback 103 + const rules: any = { 104 + prefetch: [ 105 + { 106 + source: "list", 107 + urls: [path], 108 + eagerness, 109 + }, 110 + ], 111 + }; 112 + 113 + if (prerender) { 114 + rules.prerender = [ 115 + { 116 + source: "list", 117 + urls: [path], 118 + eagerness, 119 + }, 120 + ]; 121 + } 122 + 123 + script.textContent = JSON.stringify(rules); 124 + document.head.appendChild(script); 125 + }
+20
crates/maudit/src/build/options.rs
··· 41 /// }, 42 /// prefetch: PrefetchOptions { 43 /// strategy: PrefetchStrategy::Viewport, 44 /// }, 45 /// ..Default::default() 46 /// }, ··· 80 Viewport, 81 } 82 83 #[derive(Clone)] 84 pub struct PrefetchOptions { 85 /// The prefetch strategy to use 86 pub strategy: PrefetchStrategy, 87 } 88 89 impl Default for PrefetchOptions { 90 fn default() -> Self { 91 Self { 92 strategy: PrefetchStrategy::Tap, 93 } 94 } 95 }
··· 41 /// }, 42 /// prefetch: PrefetchOptions { 43 /// strategy: PrefetchStrategy::Viewport, 44 + /// ..Default::default() 45 /// }, 46 /// ..Default::default() 47 /// }, ··· 81 Viewport, 82 } 83 84 + #[derive(Clone, Copy, PartialEq, Eq)] 85 + pub enum PrerenderEagerness { 86 + /// Prerender as soon as possible 87 + Immediate, 88 + /// Prerender eagerly but not immediately 89 + Eager, 90 + /// Prerender with moderate eagerness 91 + Moderate, 92 + /// Prerender conservatively 93 + Conservative, 94 + } 95 + 96 #[derive(Clone)] 97 pub struct PrefetchOptions { 98 /// The prefetch strategy to use 99 pub strategy: PrefetchStrategy, 100 + /// Enable prerendering using Speculation Rules API if supported. 101 + pub prerender: bool, 102 + /// Hint to the browser as to how eagerly it should prefetch/prerender. 103 + /// Only works when prerender is enabled and browser supports Speculation Rules API. 104 + pub eagerness: PrerenderEagerness, 105 } 106 107 impl Default for PrefetchOptions { 108 fn default() -> Self { 109 Self { 110 strategy: PrefetchStrategy::Tap, 111 + prerender: false, 112 + eagerness: PrerenderEagerness::Immediate, 113 } 114 } 115 }
+5
e2e/.gitignore
···
··· 1 + node_modules/ 2 + /test-results/ 3 + /playwright-report/ 4 + /blob-report/ 5 + /playwright/.cache/
+64
e2e/README.md
···
··· 1 + # E2E Tests 2 + 3 + End-to-end tests for Maudit using Playwright. 4 + 5 + ## Setup 6 + 7 + ```bash 8 + cd e2e 9 + pnpm install 10 + npx playwright install 11 + ``` 12 + 13 + ## Running Tests 14 + 15 + The tests will automatically: 16 + 1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`) 17 + 2. Start the Maudit dev server on the test fixture site 18 + 3. Run the tests 19 + 20 + ```bash 21 + # Run all tests 22 + pnpm test 23 + 24 + # Run tests in UI mode 25 + pnpm test:ui 26 + 27 + # Run tests in debug mode 28 + pnpm test:debug 29 + 30 + # Run tests with browser visible 31 + pnpm test:headed 32 + 33 + # Run tests only on Chromium (for Speculation Rules tests) 34 + pnpm test:chromium 35 + 36 + # Show test report 37 + pnpm report 38 + ``` 39 + 40 + ## Test Structure 41 + 42 + - `fixtures/test-site/` - Simple Maudit site used for testing 43 + - `tests/prefetch.spec.ts` - Tests for basic prefetch functionality 44 + - `tests/prerender.spec.ts` - Tests for Speculation Rules prerendering 45 + 46 + ## Features Tested 47 + 48 + ### Basic Prefetch 49 + - Creating link elements with `rel="prefetch"` 50 + - Preventing duplicate prefetches 51 + - Skipping current page prefetch 52 + - Blocking cross-origin prefetches 53 + 54 + ### Prerendering (Chromium only) 55 + - Creating `<script type="speculationrules">` elements 56 + - Different eagerness levels (immediate, eager, moderate, conservative) 57 + - Fallback to link prefetch on non-Chromium browsers 58 + - Multiple URL prerendering 59 + 60 + ## Notes 61 + 62 + - Speculation Rules API tests only run on Chromium (Chrome/Edge 109+) 63 + - The test server runs on `http://127.0.0.1:3456` 64 + - Tests automatically skip unsupported features on different browsers
+8
e2e/fixtures/prefetch-prerender/Cargo.toml
···
··· 1 + [package] 2 + name = "prefetch-prerender" 3 + version = "0.1.0" 4 + edition = "2024" 5 + 6 + [dependencies] 7 + maudit.workspace = true 8 + maud.workspace = true
+20
e2e/fixtures/prefetch-prerender/src/main.rs
···
··· 1 + use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 + 3 + mod pages { 4 + mod about; 5 + mod blog; 6 + mod contact; 7 + mod index; 8 + pub use about::About; 9 + pub use blog::Blog; 10 + pub use contact::Contact; 11 + pub use index::Index; 12 + } 13 + 14 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 15 + coronate( 16 + routes![pages::Index, pages::About, pages::Contact, pages::Blog], 17 + content_sources![], 18 + BuildOptions::default(), 19 + ) 20 + }
+36
e2e/fixtures/prefetch-prerender/src/pages/about.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + 4 + #[route("/about")] 5 + pub struct About; 6 + 7 + impl Route for About { 8 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 9 + Ok(html! { 10 + html { 11 + head { 12 + title { "Test Site - About" } 13 + } 14 + body { 15 + h1 { "About Page" } 16 + nav { 17 + ul { 18 + li { 19 + a href="/" { "Home" } 20 + } 21 + li { 22 + a href="/contact" { "Contact" } 23 + } 24 + li { 25 + a href="/blog" { "Blog" } 26 + } 27 + } 28 + } 29 + div id="content" { 30 + p { "This is the about page." } 31 + } 32 + } 33 + } 34 + }) 35 + } 36 + }
+36
e2e/fixtures/prefetch-prerender/src/pages/blog.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + 4 + #[route("/blog")] 5 + pub struct Blog; 6 + 7 + impl Route for Blog { 8 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 9 + Ok(html! { 10 + html { 11 + head { 12 + title { "Test Site - Blog" } 13 + } 14 + body { 15 + h1 { "Blog Page" } 16 + nav { 17 + ul { 18 + li { 19 + a href="/" { "Home" } 20 + } 21 + li { 22 + a href="/about" { "About" } 23 + } 24 + li { 25 + a href="/contact" { "Contact" } 26 + } 27 + } 28 + } 29 + div id="content" { 30 + p { "This is the blog page." } 31 + } 32 + } 33 + } 34 + }) 35 + } 36 + }
+36
e2e/fixtures/prefetch-prerender/src/pages/contact.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + 4 + #[route("/contact")] 5 + pub struct Contact; 6 + 7 + impl Route for Contact { 8 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 9 + Ok(html! { 10 + html { 11 + head { 12 + title { "Test Site - Contact" } 13 + } 14 + body { 15 + h1 { "Contact Page" } 16 + nav { 17 + ul { 18 + li { 19 + a href="/" { "Home" } 20 + } 21 + li { 22 + a href="/about" { "About" } 23 + } 24 + li { 25 + a href="/blog" { "Blog" } 26 + } 27 + } 28 + } 29 + div id="content" { 30 + p { "This is the contact page." } 31 + } 32 + } 33 + } 34 + }) 35 + } 36 + }
+36
e2e/fixtures/prefetch-prerender/src/pages/index.rs
···
··· 1 + use maud::html; 2 + use maudit::route::prelude::*; 3 + 4 + #[route("/")] 5 + pub struct Index; 6 + 7 + impl Route for Index { 8 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 9 + Ok(html! { 10 + html { 11 + head { 12 + title { "Test Site - Home" } 13 + } 14 + body { 15 + h1 { "Home Page" } 16 + nav { 17 + ul { 18 + li { 19 + a href="/about" { "About" } 20 + } 21 + li { 22 + a href="/contact" { "Contact" } 23 + } 24 + li { 25 + a href="/blog" { "Blog" } 26 + } 27 + } 28 + } 29 + div id="content" { 30 + p { "Welcome to the test site!" } 31 + } 32 + } 33 + } 34 + }) 35 + } 36 + }
+19
e2e/package.json
···
··· 1 + { 2 + "name": "@maudit/e2e", 3 + "version": "0.0.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "build-js": "cargo xtask build-maudit-js", 8 + "test": "playwright test", 9 + "test:ui": "playwright test --ui", 10 + "test:debug": "playwright test --debug", 11 + "test:headed": "playwright test --headed", 12 + "test:chromium": "playwright test --project=chromium", 13 + "report": "playwright show-report" 14 + }, 15 + "devDependencies": { 16 + "@playwright/test": "^1.49.1", 17 + "@types/node": "^22.10.5" 18 + } 19 + }
+31
e2e/playwright.config.ts
···
··· 1 + import { defineConfig, devices } from "@playwright/test"; 2 + 3 + /** 4 + * See https://playwright.dev/docs/test-configuration. 5 + */ 6 + export default defineConfig({ 7 + testDir: "./tests", 8 + /* Run tests in files in parallel */ 9 + fullyParallel: true, 10 + /* Fail the build on CI if you accidentally left test.only in the source code. */ 11 + forbidOnly: !!process.env.CI, 12 + /* Retry on CI only */ 13 + retries: process.env.CI ? 2 : 0, 14 + /* Opt out of parallel tests on CI. */ 15 + workers: process.env.CI ? 1 : undefined, 16 + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 17 + reporter: "html", 18 + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 19 + use: { 20 + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 21 + trace: "on-first-retry", 22 + }, 23 + 24 + /* Configure projects for major browsers */ 25 + projects: [ 26 + { 27 + name: "chromium", 28 + use: { ...devices["Desktop Chrome"] }, 29 + }, 30 + ], 31 + });
+153
e2e/tests/prefetch.spec.ts
···
··· 1 + import { test, expect } from "./test-utils"; 2 + import { prefetchScript } from "./utils"; 3 + 4 + test.describe("Prefetch", () => { 5 + test("should create prefetch via speculation rules on Chromium or link element elsewhere", async ({ 6 + page, 7 + browserName, 8 + devServer, 9 + }) => { 10 + await page.goto(devServer.url); 11 + 12 + // Inject prefetch function 13 + await page.addScriptTag({ content: prefetchScript }); 14 + 15 + // Call prefetch 16 + await page.evaluate(() => { 17 + window.prefetch("/about/"); 18 + }); 19 + 20 + if (browserName === "chromium") { 21 + // Chromium: Should create a speculation rules script with prefetch 22 + const speculationScript = page.locator('script[type="speculationrules"]').first(); 23 + const scriptContent = await speculationScript.textContent(); 24 + expect(scriptContent).toBeTruthy(); 25 + if (scriptContent) { 26 + const rules = JSON.parse(scriptContent); 27 + expect(rules.prefetch).toBeDefined(); 28 + expect(rules.prefetch[0].urls).toContain("/about/"); 29 + } 30 + } else { 31 + // Non-Chromium: If link prefetch is supported, assert link element; otherwise, ensure no speculation script 32 + const supportsPrefetch = await page.evaluate(() => { 33 + const link = document.createElement("link"); 34 + // Some browsers may not support relList.supports('prefetch') 35 + return !!(link.relList && link.relList.supports && link.relList.supports("prefetch")); 36 + }); 37 + 38 + if (supportsPrefetch) { 39 + const prefetchLink = page.locator('link[rel="prefetch"]').first(); 40 + await expect(prefetchLink).toHaveAttribute("href", "/about/"); 41 + } else { 42 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 43 + expect(speculationScripts.length).toBe(0); 44 + } 45 + } 46 + }); 47 + 48 + test("should not prefetch same URL twice", async ({ page, browserName, devServer }) => { 49 + await page.goto(devServer.url); 50 + 51 + await page.addScriptTag({ content: prefetchScript }); 52 + 53 + // Call prefetch twice 54 + await page.evaluate(() => { 55 + window.prefetch("/about/"); 56 + window.prefetch("/about/"); 57 + }); 58 + 59 + if (browserName === "chromium") { 60 + // Should only have one speculation rules script 61 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 62 + expect(speculationScripts.length).toBe(1); 63 + const scriptContent = await speculationScripts[0].textContent(); 64 + if (scriptContent) { 65 + const rules = JSON.parse(scriptContent); 66 + expect(rules.prefetch).toBeDefined(); 67 + expect(rules.prefetch[0].urls).toContain("/about/"); 68 + } 69 + } else { 70 + // Non-Chromium: If link prefetch is supported, expect one link; otherwise, expect no speculation script 71 + const supportsPrefetch = await page.evaluate(() => { 72 + const link = document.createElement("link"); 73 + return !!(link.relList && link.relList.supports && link.relList.supports("prefetch")); 74 + }); 75 + 76 + if (supportsPrefetch) { 77 + const prefetchLinks = await page.locator('link[rel="prefetch"]').all(); 78 + expect(prefetchLinks.length).toBe(1); 79 + } else { 80 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 81 + expect(speculationScripts.length).toBe(0); 82 + } 83 + } 84 + }); 85 + 86 + test("should not prefetch current page", async ({ page, browserName, devServer }) => { 87 + await page.goto(`${devServer.url}/about/`); 88 + 89 + await page.addScriptTag({ content: prefetchScript }); 90 + 91 + // Try to prefetch current page 92 + await page.evaluate(() => { 93 + window.prefetch("/about/"); 94 + }); 95 + 96 + if (browserName === "chromium") { 97 + // Should not create any speculation rules script 98 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 99 + expect(speculationScripts.length).toBe(0); 100 + } else { 101 + // Should not create any link element 102 + const prefetchLinks = await page.locator('link[rel="prefetch"]').all(); 103 + expect(prefetchLinks.length).toBe(0); 104 + } 105 + }); 106 + 107 + test("should not prefetch cross-origin URLs", async ({ page, browserName, devServer }) => { 108 + await page.goto(devServer.url); 109 + 110 + await page.addScriptTag({ content: prefetchScript }); 111 + 112 + // Try to prefetch cross-origin URL 113 + await page.evaluate(() => { 114 + window.prefetch("https://example.com/about/"); 115 + }); 116 + 117 + if (browserName === "chromium") { 118 + // Should not create any speculation rules script 119 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 120 + expect(speculationScripts.length).toBe(0); 121 + } else { 122 + // Should not create any link element 123 + const prefetchLinks = await page.locator('link[rel="prefetch"]').all(); 124 + expect(prefetchLinks.length).toBe(0); 125 + } 126 + }); 127 + 128 + test("should use correct eagerness level without prerender", async ({ 129 + page, 130 + browserName, 131 + devServer, 132 + }) => { 133 + test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium"); 134 + 135 + await page.goto(devServer.url); 136 + 137 + await page.addScriptTag({ content: prefetchScript }); 138 + 139 + // Call prefetch with custom eagerness but no prerender 140 + await page.evaluate(() => { 141 + window.prefetch("/about/", { eagerness: "moderate" }); 142 + }); 143 + 144 + const speculationScript = page.locator('script[type="speculationrules"]').first(); 145 + const scriptContent = await speculationScript.textContent(); 146 + 147 + if (scriptContent) { 148 + const rules = JSON.parse(scriptContent); 149 + expect(rules.prefetch[0].eagerness).toBe("moderate"); 150 + expect(rules.prerender).toBeUndefined(); 151 + } 152 + }); 153 + });
+145
e2e/tests/prerender.spec.ts
···
··· 1 + import { test, expect } from "./test-utils"; 2 + import { prefetchScript } from "./utils"; 3 + 4 + test.describe("Prefetch - Speculation Rules (Prerender)", () => { 5 + test("should create speculation rules on Chromium or link prefetch elsewhere when prerender is enabled", async ({ 6 + page, 7 + browserName, 8 + devServer, 9 + }) => { 10 + await page.goto(devServer.url); 11 + 12 + await page.addScriptTag({ content: prefetchScript }); 13 + 14 + // Call prefetch with prerender 15 + await page.evaluate(() => { 16 + window.prefetch("/about/", { prerender: true }); 17 + }); 18 + 19 + if (browserName === "chromium") { 20 + // Chromium: should create speculation rules script including prerender and prefetch 21 + const speculationScript = page.locator('script[type="speculationrules"]').first(); 22 + const scriptContent = await speculationScript.textContent(); 23 + expect(scriptContent).toBeTruthy(); 24 + 25 + if (scriptContent) { 26 + const rules = JSON.parse(scriptContent); 27 + expect(rules.prerender).toBeDefined(); 28 + expect(rules.prerender[0].urls).toContain("/about/"); 29 + expect(rules.prefetch).toBeDefined(); // Fallback 30 + expect(rules.prefetch[0].urls).toContain("/about/"); 31 + } 32 + } else { 33 + // Non-Chromium: If link prefetch is supported, assert link element; otherwise, ensure no speculation script 34 + const supportsPrefetch = await page.evaluate(() => { 35 + const link = document.createElement("link"); 36 + return !!(link.relList && link.relList.supports && link.relList.supports("prefetch")); 37 + }); 38 + 39 + if (supportsPrefetch) { 40 + const prefetchLink = page.locator('link[rel="prefetch"]').first(); 41 + await expect(prefetchLink).toHaveAttribute("href", "/about/"); 42 + } else { 43 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 44 + expect(speculationScripts.length).toBe(0); 45 + } 46 + } 47 + }); 48 + 49 + test("should use correct eagerness level", async ({ page, browserName, devServer }) => { 50 + test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium"); 51 + 52 + await page.goto(devServer.url); 53 + 54 + await page.addScriptTag({ content: prefetchScript }); 55 + 56 + // Call prefetch with custom eagerness 57 + await page.evaluate(() => { 58 + window.prefetch("/about/", { prerender: true, eagerness: "conservative" }); 59 + }); 60 + 61 + const speculationScript = page.locator('script[type="speculationrules"]').first(); 62 + const scriptContent = await speculationScript.textContent(); 63 + 64 + if (scriptContent) { 65 + const rules = JSON.parse(scriptContent); 66 + expect(rules.prerender[0].eagerness).toBe("conservative"); 67 + expect(rules.prefetch[0].eagerness).toBe("conservative"); 68 + } 69 + }); 70 + 71 + test("should fallback to link prefetch when speculation rules not supported", async ({ 72 + page, 73 + browserName, 74 + devServer, 75 + }) => { 76 + // Run this test on Firefox/Safari where Speculation Rules is not supported 77 + test.skip(browserName === "chromium", "Testing fallback behavior on non-Chromium browsers"); 78 + 79 + await page.goto(devServer.url); 80 + 81 + await page.addScriptTag({ content: prefetchScript }); 82 + 83 + // Call prefetch with prerender (should fallback to link) 84 + await page.evaluate(() => { 85 + window.prefetch("/about/", { prerender: true }); 86 + }); 87 + 88 + // Check if browser supports link prefetch 89 + const supportsPrefetch = await page.evaluate(() => { 90 + const link = document.createElement("link"); 91 + return !!(link.relList && link.relList.supports && link.relList.supports("prefetch")); 92 + }); 93 + 94 + if (supportsPrefetch) { 95 + // Should create link element instead 96 + const prefetchLink = page.locator('link[rel="prefetch"]').first(); 97 + await expect(prefetchLink).toHaveAttribute("href", "/about/"); 98 + } 99 + 100 + // Should NOT create speculation rules script 101 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 102 + expect(speculationScripts.length).toBe(0); 103 + }); 104 + 105 + test("should not prerender same URL twice", async ({ page, browserName, devServer }) => { 106 + test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium"); 107 + 108 + await page.goto(devServer.url); 109 + 110 + await page.addScriptTag({ content: prefetchScript }); 111 + 112 + // Call prefetch with prerender twice 113 + await page.evaluate(() => { 114 + window.prefetch("/about/", { prerender: true }); 115 + window.prefetch("/about/", { prerender: true }); 116 + }); 117 + 118 + // Should only have one speculation rules script 119 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 120 + expect(speculationScripts.length).toBe(1); 121 + }); 122 + 123 + test("should create separate scripts for different URLs", async ({ 124 + page, 125 + browserName, 126 + devServer, 127 + }) => { 128 + test.skip(browserName !== "chromium", "Speculation Rules only supported in Chromium"); 129 + 130 + await page.goto(devServer.url); 131 + 132 + await page.addScriptTag({ content: prefetchScript }); 133 + 134 + // Prerender multiple URLs 135 + await page.evaluate(() => { 136 + window.prefetch("/about/", { prerender: true }); 137 + window.prefetch("/contact/", { prerender: true }); 138 + window.prefetch("/blog/", { prerender: true }); 139 + }); 140 + 141 + // Should have three separate scripts (one per URL) 142 + const speculationScripts = await page.locator('script[type="speculationrules"]').all(); 143 + expect(speculationScripts.length).toBe(3); 144 + }); 145 + });
+168
e2e/tests/test-utils.ts
···
··· 1 + import { spawn, execFile, type ChildProcess } from "node:child_process"; 2 + import { join, resolve, dirname } from "node:path"; 3 + import { existsSync } from "node:fs"; 4 + import { fileURLToPath } from "node:url"; 5 + import { test as base } from "@playwright/test"; 6 + 7 + const __filename = fileURLToPath(import.meta.url); 8 + const __dirname = dirname(__filename); 9 + 10 + export interface DevServerOptions { 11 + /** Path to the fixture directory relative to e2e/fixtures/ */ 12 + fixture: string; 13 + /** Port to run the server on (default: auto-find) */ 14 + port?: number; 15 + /** Additional CLI flags to pass to maudit dev */ 16 + flags?: string[]; 17 + } 18 + 19 + export interface DevServer { 20 + /** Base URL of the dev server */ 21 + url: string; 22 + /** Port the server is running on */ 23 + port: number; 24 + /** Stop the dev server */ 25 + stop: () => Promise<void>; 26 + } 27 + 28 + /** 29 + * Start a maudit dev server for testing. 30 + */ 31 + export async function startDevServer(options: DevServerOptions): Promise<DevServer> { 32 + // Use __dirname (test file location) to reliably find paths 33 + const e2eRoot = resolve(__dirname, ".."); 34 + const fixturePath = resolve(e2eRoot, "fixtures", options.fixture); 35 + const flags = options.flags || []; 36 + const command = resolve(e2eRoot, "..", "target", "debug", "maudit"); 37 + 38 + // Verify the binary exists 39 + if (!existsSync(command)) { 40 + throw new Error( 41 + `Maudit binary not found at: ${command}. Please build it with 'cargo build --bin maudit'`, 42 + ); 43 + } 44 + 45 + // Build args array 46 + const args = ["dev", ...flags]; 47 + if (options.port) { 48 + args.push("--port", options.port.toString()); 49 + } 50 + 51 + // Start the dev server process 52 + const childProcess = spawn(command, args, { 53 + cwd: fixturePath, 54 + stdio: ["ignore", "pipe", "pipe"], 55 + }); 56 + 57 + // Capture output to detect when server is ready 58 + let serverReady = false; 59 + 60 + const outputPromise = new Promise<number>((resolve, reject) => { 61 + const timeout = setTimeout(() => { 62 + reject(new Error("Dev server did not start within 30 seconds")); 63 + }, 30000); 64 + 65 + childProcess.stdout?.on("data", (data: Buffer) => { 66 + const output = data.toString(); 67 + 68 + // Look for "waiting for requests" to know server is ready 69 + if (output.includes("waiting for requests")) { 70 + serverReady = true; 71 + clearTimeout(timeout); 72 + // We already know the port from options, so just resolve with it 73 + resolve(options.port || 1864); 74 + } 75 + }); 76 + 77 + childProcess.stderr?.on("data", (data: Buffer) => { 78 + // Only log errors, not all stderr output 79 + const output = data.toString(); 80 + if (output.toLowerCase().includes("error")) { 81 + console.error(`[maudit dev] ${output}`); 82 + } 83 + }); 84 + 85 + childProcess.on("error", (error) => { 86 + clearTimeout(timeout); 87 + reject(new Error(`Failed to start dev server: ${error.message}`)); 88 + }); 89 + 90 + childProcess.on("exit", (code) => { 91 + if (!serverReady) { 92 + clearTimeout(timeout); 93 + reject(new Error(`Dev server exited with code ${code} before becoming ready`)); 94 + } 95 + }); 96 + }); 97 + 98 + const port = await outputPromise; 99 + 100 + return { 101 + url: `http://127.0.0.1:${port}`, 102 + port, 103 + stop: async () => { 104 + return new Promise((resolve) => { 105 + childProcess.on("exit", () => resolve()); 106 + childProcess.kill("SIGTERM"); 107 + 108 + // Force kill after 5 seconds if it doesn't stop gracefully 109 + setTimeout(() => { 110 + if (!childProcess.killed) { 111 + childProcess.kill("SIGKILL"); 112 + } 113 + }, 5000); 114 + }); 115 + }, 116 + }; 117 + } 118 + 119 + /** 120 + * Helper to manage multiple dev servers in tests. 121 + * Automatically cleans up servers when tests finish. 122 + */ 123 + export class DevServerPool { 124 + private servers: DevServer[] = []; 125 + 126 + async start(options: DevServerOptions): Promise<DevServer> { 127 + const server = await startDevServer(options); 128 + this.servers.push(server); 129 + return server; 130 + } 131 + 132 + async stopAll(): Promise<void> { 133 + await Promise.all(this.servers.map((server) => server.stop())); 134 + this.servers = []; 135 + } 136 + } 137 + 138 + // Worker-scoped server pool - one server per worker, shared across all tests in that worker 139 + const workerServers = new Map<number, DevServer>(); 140 + 141 + // Extend Playwright's test with a devServer fixture 142 + export const test = base.extend<{ devServer: DevServer }>({ 143 + devServer: async ({}, use, testInfo) => { 144 + // Use worker index to get or create a server for this worker 145 + const workerIndex = testInfo.workerIndex; 146 + 147 + let server = workerServers.get(workerIndex); 148 + 149 + if (!server) { 150 + // Assign unique port based on worker index 151 + const port = 1864 + workerIndex; 152 + 153 + server = await startDevServer({ 154 + fixture: "prefetch-prerender", 155 + port, 156 + }); 157 + 158 + workerServers.set(workerIndex, server); 159 + } 160 + 161 + await use(server); 162 + 163 + // Don't stop the server here - it stays alive for all tests in this worker 164 + // Playwright will clean up when the worker exits 165 + }, 166 + }); 167 + 168 + export { expect } from "@playwright/test";
+25
e2e/tests/utils.ts
···
··· 1 + import { readFileSync, readdirSync } from "node:fs"; 2 + import { join } from "node:path"; 3 + 4 + // Find the actual prefetch bundle file (hash changes on each build) 5 + const distDir = join(process.cwd(), "../crates/maudit/js/dist"); 6 + const prefetchFile = readdirSync(distDir).find( 7 + (f) => f.startsWith("prefetch-") && f.endsWith(".js"), 8 + ); 9 + if (!prefetchFile) throw new Error("Could not find prefetch bundle"); 10 + 11 + // Read the bundled prefetch script 12 + const prefetchBundled = readFileSync(join(distDir, prefetchFile), "utf-8"); 13 + 14 + // Extract the internal function name from export{X as prefetch} 15 + const exportMatch = prefetchBundled.match(/export\{(\w+) as prefetch\}/); 16 + if (!exportMatch) throw new Error("Could not parse prefetch export"); 17 + const internalName = exportMatch[1]; 18 + 19 + // Remove export and expose on window 20 + export const prefetchScript = ` 21 + (function() { 22 + ${prefetchBundled.replace(/export\{.*\};?$/, "")} 23 + window.prefetch = ${internalName}; 24 + })(); 25 + `;
+14
e2e/tsconfig.json
···
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ES2022", 5 + "lib": ["ES2022", "DOM"], 6 + "moduleResolution": "bundler", 7 + "strict": true, 8 + "skipLibCheck": true, 9 + "resolveJsonModule": true, 10 + "esModuleInterop": true, 11 + "types": ["node", "@playwright/test"] 12 + }, 13 + "include": ["tests/**/*", "playwright.config.ts", "types.d.ts"] 14 + }
+7
e2e/types.d.ts
···
··· 1 + declare global { 2 + interface Window { 3 + prefetch: (url: string, options?: { prerender?: boolean; eagerness?: string }) => void; 4 + } 5 + } 6 + 7 + export {};
+56
pnpm-lock.yaml
··· 34 specifier: ^6.0.6 35 version: 6.0.6 36 37 packages: 38 39 '@jridgewell/gen-mapping@0.3.13': ··· 243 '@parcel/watcher@2.5.1': 244 resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} 245 engines: {node: '>= 10.0.0'} 246 247 '@tailwindcss/cli@4.1.18': 248 resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==} ··· 333 resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} 334 engines: {node: '>= 10'} 335 336 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260109.1': 337 resolution: {integrity: sha512-rEY7JFH9JhIQ7SCjD+cpwPhIBLzNOgA7IVkfIcOpbWTmtOufx0sTZejR5B2b81x2fLCJDPZGpUv71wD1LP45iA==} 338 cpu: [arm64] ··· 395 fill-range@7.1.1: 396 resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} 397 engines: {node: '>=8'} 398 399 graceful-fs@4.2.11: 400 resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} ··· 525 resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 526 engines: {node: '>=8.6'} 527 528 source-map-js@1.2.1: 529 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 530 engines: {node: '>=0.10.0'} ··· 543 to-regex-range@5.0.1: 544 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 545 engines: {node: '>=8.0'} 546 547 snapshots: 548 ··· 691 '@parcel/watcher-win32-ia32': 2.5.1 692 '@parcel/watcher-win32-x64': 2.5.1 693 694 '@tailwindcss/cli@4.1.18': 695 dependencies: 696 '@parcel/watcher': 2.5.1 ··· 762 '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 763 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 764 765 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260109.1': 766 optional: true 767 ··· 811 fill-range@7.1.1: 812 dependencies: 813 to-regex-range: 5.0.1 814 815 graceful-fs@4.2.11: {} 816 ··· 924 925 picomatch@2.3.1: {} 926 927 source-map-js@1.2.1: {} 928 929 tailwindcss@4.1.18: {} ··· 935 to-regex-range@5.0.1: 936 dependencies: 937 is-number: 7.0.0
··· 34 specifier: ^6.0.6 35 version: 6.0.6 36 37 + e2e: 38 + devDependencies: 39 + '@playwright/test': 40 + specifier: ^1.49.1 41 + version: 1.58.0 42 + '@types/node': 43 + specifier: ^22.10.5 44 + version: 22.19.7 45 + 46 packages: 47 48 '@jridgewell/gen-mapping@0.3.13': ··· 252 '@parcel/watcher@2.5.1': 253 resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} 254 engines: {node: '>= 10.0.0'} 255 + 256 + '@playwright/test@1.58.0': 257 + resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==} 258 + engines: {node: '>=18'} 259 + hasBin: true 260 261 '@tailwindcss/cli@4.1.18': 262 resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==} ··· 347 resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} 348 engines: {node: '>= 10'} 349 350 + '@types/node@22.19.7': 351 + resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} 352 + 353 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260109.1': 354 resolution: {integrity: sha512-rEY7JFH9JhIQ7SCjD+cpwPhIBLzNOgA7IVkfIcOpbWTmtOufx0sTZejR5B2b81x2fLCJDPZGpUv71wD1LP45iA==} 355 cpu: [arm64] ··· 412 fill-range@7.1.1: 413 resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} 414 engines: {node: '>=8'} 415 + 416 + fsevents@2.3.2: 417 + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} 418 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 419 + os: [darwin] 420 421 graceful-fs@4.2.11: 422 resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} ··· 547 resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 548 engines: {node: '>=8.6'} 549 550 + playwright-core@1.58.0: 551 + resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} 552 + engines: {node: '>=18'} 553 + hasBin: true 554 + 555 + playwright@1.58.0: 556 + resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==} 557 + engines: {node: '>=18'} 558 + hasBin: true 559 + 560 source-map-js@1.2.1: 561 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 562 engines: {node: '>=0.10.0'} ··· 575 to-regex-range@5.0.1: 576 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 577 engines: {node: '>=8.0'} 578 + 579 + undici-types@6.21.0: 580 + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 581 582 snapshots: 583 ··· 726 '@parcel/watcher-win32-ia32': 2.5.1 727 '@parcel/watcher-win32-x64': 2.5.1 728 729 + '@playwright/test@1.58.0': 730 + dependencies: 731 + playwright: 1.58.0 732 + 733 '@tailwindcss/cli@4.1.18': 734 dependencies: 735 '@parcel/watcher': 2.5.1 ··· 801 '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 802 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 803 804 + '@types/node@22.19.7': 805 + dependencies: 806 + undici-types: 6.21.0 807 + 808 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260109.1': 809 optional: true 810 ··· 854 fill-range@7.1.1: 855 dependencies: 856 to-regex-range: 5.0.1 857 + 858 + fsevents@2.3.2: 859 + optional: true 860 861 graceful-fs@4.2.11: {} 862 ··· 970 971 picomatch@2.3.1: {} 972 973 + playwright-core@1.58.0: {} 974 + 975 + playwright@1.58.0: 976 + dependencies: 977 + playwright-core: 1.58.0 978 + optionalDependencies: 979 + fsevents: 2.3.2 980 + 981 source-map-js@1.2.1: {} 982 983 tailwindcss@4.1.18: {} ··· 989 to-regex-range@5.0.1: 990 dependencies: 991 is-number: 7.0.0 992 + 993 + undici-types@6.21.0: {}
+1
pnpm-workspace.yaml
··· 1 packages: 2 - crates/* 3 4 onlyBuiltDependencies: 5 - "@parcel/watcher"
··· 1 packages: 2 - crates/* 3 + - e2e 4 5 onlyBuiltDependencies: 6 - "@parcel/watcher"
+30 -1
website/content/docs/prefetching.md
··· 11 12 ## Configuration 13 14 - Prefetching can be configured using the `prefetch` property of [`BuildOptions`](https://docs.rs/maudit/latest/maudit/struct.BuildOptions.html) which takes a [`PrefetchOptions`](https://docs.rs/maudit/latest/maudit/struct.PrefetchOptions.html) struct. Currently, the only option is `strategy`. 15 16 ```rs 17 use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy}; ··· 19 BuildOptions { 20 prefetch: PrefetchOptions { 21 strategy: PrefetchStrategy::Hover, 22 }, 23 ..Default.default() 24 } 25 ``` 26 27 To disable prefetching, set `strategy` to [`PrefetchStrategy::None`](https://docs.rs/maudit/latest/maudit/enum.PrefetchStrategy.html#variant.None).
··· 11 12 ## Configuration 13 14 + Prefetching can be configured using the `prefetch` property of [`BuildOptions`](https://docs.rs/maudit/latest/maudit/struct.BuildOptions.html) which takes a [`PrefetchOptions`](https://docs.rs/maudit/latest/maudit/struct.PrefetchOptions.html) struct. 15 16 ```rs 17 use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy}; ··· 19 BuildOptions { 20 prefetch: PrefetchOptions { 21 strategy: PrefetchStrategy::Hover, 22 + ..Default::default() 23 }, 24 ..Default.default() 25 } 26 ``` 27 28 To disable prefetching, set `strategy` to [`PrefetchStrategy::None`](https://docs.rs/maudit/latest/maudit/enum.PrefetchStrategy.html#variant.None). 29 + 30 + ## Using the speculation rules API 31 + 32 + Maudit will automatically uses the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API) to prefetch instead of `<link rel="prefetch">` tags when supported by the browser. 33 + 34 + ### Prerendering 35 + 36 + By enabling `PrefetchOptions.prerender`, Maudit will also prerender your prefetched pages using the Speculation Rules API. 37 + 38 + ```rs 39 + use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy}; 40 + 41 + BuildOptions { 42 + prefetch: PrefetchOptions { 43 + prerender: true, 44 + ..Default::default() 45 + }, 46 + ..Default.default() 47 + } 48 + ``` 49 + 50 + Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views. 51 + 52 + ## Possible risks 53 + 54 + Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening. 55 + 56 + Additionally, the performance improvements provided by prefetching will, in the vast majority of cases, trump any possible resource wastage (of which the potential is low in the first place).
+2 -2
website/src/layout.rs
··· 1 - use maud::{DOCTYPE, Markup, PreEscaped, html}; 2 use chrono::{Datelike, Utc}; 3 mod docs_sidebars; 4 mod header; 5 ··· 152 meta name="viewport" content="width=device-width, initial-scale=1"; 153 (generator()) 154 link rel="icon" href="/favicon.svg"; 155 - (seo_data.render(&ctx.base_url)) 156 } 157 body { 158 div.relative.bg-our-white {
··· 1 use chrono::{Datelike, Utc}; 2 + use maud::{DOCTYPE, Markup, PreEscaped, html}; 3 mod docs_sidebars; 4 mod header; 5 ··· 152 meta name="viewport" content="width=device-width, initial-scale=1"; 153 (generator()) 154 link rel="icon" href="/favicon.svg"; 155 + (seo_data.render(ctx.base_url)) 156 } 157 body { 158 div.relative.bg-our-white {
+8 -2
xtask/src/main.rs
··· 49 50 println!("Building JavaScript for maudit-cli..."); 51 52 - // Ensure the dist directory exists 53 fs::create_dir_all(&js_dist_dir)?; 54 55 // Configure Rolldown bundler input ··· 98 99 println!("Building JavaScript for maudit..."); 100 101 - // Ensure the dist directory exists 102 fs::create_dir_all(&js_dist_dir)?; 103 104 // Configure Rolldown bundler input
··· 49 50 println!("Building JavaScript for maudit-cli..."); 51 52 + // Remove and recreate the dist directory to clean old builds 53 + if js_dist_dir.exists() { 54 + fs::remove_dir_all(&js_dist_dir)?; 55 + } 56 fs::create_dir_all(&js_dist_dir)?; 57 58 // Configure Rolldown bundler input ··· 101 102 println!("Building JavaScript for maudit..."); 103 104 + // Remove and recreate the dist directory to clean old builds 105 + if js_dist_dir.exists() { 106 + fs::remove_dir_all(&js_dist_dir)?; 107 + } 108 fs::create_dir_all(&js_dist_dir)?; 109 110 // Configure Rolldown bundler input