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 77 - name: Build 78 78 run: cargo build 79 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 + 80 112 test: 81 113 name: Test 82 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 3826 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3827 3827 3828 3828 [[package]] 3829 + name = "prefetch-prerender" 3830 + version = "0.1.0" 3831 + dependencies = [ 3832 + "maud", 3833 + "maudit", 3834 + ] 3835 + 3836 + [[package]] 3829 3837 name = "proc-macro-crate" 3830 3838 version = "3.4.0" 3831 3839 source = "registry+https://github.com/rust-lang/crates.io-index"
+1 -1
Cargo.toml
··· 1 1 [workspace] 2 - members = ["crates/*", "benchmarks/*", "examples/*", "website", "xtask"] 2 + members = ["crates/*", "benchmarks/*", "examples/*", "website", "xtask", "e2e/fixtures/*"] 3 3 resolver = "3" 4 4 5 5 [workspace.dependencies]
+1
benchmarks/md-benchmark/src/lib.rs
··· 15 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 16 // but not really in this benchmark. 17 17 strategy: PrefetchStrategy::None, 18 + ..Default::default() 18 19 }, 19 20 ..Default::default() 20 21 },
+1
benchmarks/overhead/src/lib.rs
··· 10 10 // This benchmark is really about testing Maudit's overhead, if we enable prefetching then a lot of time 11 11 // is spent in bundling, including the script in pages, etc. instead of Maudit itself. 12 12 strategy: PrefetchStrategy::None, 13 + ..Default::default() 13 14 }, 14 15 ..Default::default() 15 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 12 use server::WebSocketMessage; 13 13 use std::{fs, path::Path}; 14 14 use tokio::{ 15 + signal, 15 16 sync::{broadcast, mpsc::channel}, 16 17 task::JoinHandle, 17 18 }; ··· 19 20 20 21 use crate::dev::build::BuildManager; 21 22 22 - pub async fn start_dev_env(cwd: &str, host: bool) -> Result<(), Box<dyn std::error::Error>> { 23 + pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> { 23 24 let start_time = Instant::now(); 24 25 info!(name: "dev", "Preparing dev environment…"); 25 26 ··· 74 75 start_time, 75 76 sender_websocket.clone(), 76 77 host, 78 + port, 77 79 None, 78 80 build_manager.current_status(), 79 81 ))); ··· 147 149 start_time, 148 150 sender_websocket_watcher.clone(), 149 151 host, 152 + port, 150 153 None, 151 154 build_manager_watcher.current_status(), 152 155 ))); ··· 196 199 } 197 200 }); 198 201 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 => {}, 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..."); 204 206 } 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(); 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 + } => {} 209 219 } 210 220 Ok(()) 211 221 } ··· 248 258 249 259 true 250 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 29 use axum::extract::connect_info::ConnectInfo; 30 30 use futures::{SinkExt, stream::StreamExt}; 31 31 32 + use crate::consts::PORT; 32 33 use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start}; 33 34 use axum::http::header; 34 35 use local_ip_address::local_ip; ··· 94 95 start_time: Instant, 95 96 tx: broadcast::Sender<WebSocketMessage>, 96 97 host: bool, 98 + port: Option<u16>, 97 99 initial_error: Option<String>, 98 100 current_status: Arc<RwLock<Option<PersistentStatus>>>, 99 101 ) { ··· 131 133 } else { 132 134 IpAddr::from([127, 0, 0, 1]) 133 135 }; 134 - let port = find_open_port(&addr, 1864).await; 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; 135 141 let socket = TcpSocket::new_v4().unwrap(); 136 142 let _ = socket.set_reuseaddr(true); 137 - let _ = socket.set_reuseport(true); 138 143 139 144 let socket_addr = SocketAddr::new(addr, port); 140 145 socket.bind(socket_addr).unwrap();
+8 -2
crates/maudit-cli/src/main.rs
··· 3 3 mod init; 4 4 mod preview; 5 5 6 + mod consts; 7 + 6 8 mod logging; 7 9 mod server_utils; 8 10 ··· 33 35 Dev { 34 36 #[clap(long)] 35 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, 36 42 }, 37 43 /// Preview the project 38 44 Preview { ··· 67 73 68 74 let _ = start_preview_web_server(PathBuf::from("dist"), *host).await; 69 75 } 70 - Commands::Dev { host } => { 76 + Commands::Dev { host, port } => { 71 77 // TODO: cwd should be configurable, ex: --root <path> 72 - let _ = start_dev_env(".", *host).await; 78 + let _ = start_dev_env(".", *host, Some(*port)).await; 73 79 } 74 80 } 75 81 }
+2 -1
crates/maudit-cli/src/preview/server.rs
··· 16 16 trace::{DefaultMakeSpan, TraceLayer}, 17 17 }; 18 18 19 + use crate::consts::PORT; 19 20 use crate::server_utils::{CustomOnResponse, find_open_port, log_server_start}; 20 21 21 22 pub async fn start_preview_web_server(dist_dir: PathBuf, host: bool) { ··· 42 43 IpAddr::from([127, 0, 0, 1]) 43 44 }; 44 45 45 - let port = find_open_port(&addr, 1864).await; 46 + let port = find_open_port(&addr, PORT).await; 46 47 let socket = TcpSocket::new_v4().unwrap(); 47 48 let _ = socket.set_reuseaddr(true); 48 49 let _ = socket.set_reuseport(true);
+4 -5
crates/maudit-macros/src/lib.rs
··· 247 247 } 248 248 } 249 249 LocaleKind::Prefix(prefix) => { 250 - if args.path.is_none() { 251 - // Emit compile error if prefix is used without base path 250 + if let Some(base_path) = args.path.as_ref() { 252 251 quote! { 253 - compile_error!("Cannot use locale prefix without a base route path") 252 + (#locale_name.to_string(), format!("{}{}", #prefix, #base_path)) 254 253 } 255 254 } else { 256 - let base_path = args.path.as_ref().unwrap(); 255 + // Emit compile error if prefix is used without base path 257 256 quote! { 258 - (#locale_name.to_string(), format!("{}{}", #prefix, #base_path)) 257 + compile_error!("Cannot use locale prefix without a base route path") 259 258 } 260 259 } 261 260 }
+74 -2
crates/maudit/js/prefetch.ts
··· 2 2 3 3 interface PreloadConfig { 4 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"; 5 21 } 6 22 7 23 export function prefetch(url: string, config?: PreloadConfig) { ··· 14 30 } 15 31 16 32 const skipConnectionCheck = config?.skipConnectionCheck ?? false; 33 + const shouldPrerender = config?.prerender ?? false; 34 + const eagerness = config?.eagerness ?? "immediate"; 17 35 18 36 if (!canPrefetchUrl(urlObj, skipConnectionCheck)) { 19 37 return; 20 38 } 21 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 22 52 const linkElement = document.createElement("link"); 23 53 const supportsPrefetch = linkElement.relList?.supports?.("prefetch"); 24 54 25 55 if (supportsPrefetch) { 26 56 linkElement.rel = "prefetch"; 27 - linkElement.href = url; 57 + linkElement.href = path; 28 58 document.head.appendChild(linkElement); 29 - preloadedUrls.add(urlObj.href); 30 59 } 31 60 } 32 61 ··· 51 80 52 81 return false; 53 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 41 /// }, 42 42 /// prefetch: PrefetchOptions { 43 43 /// strategy: PrefetchStrategy::Viewport, 44 + /// ..Default::default() 44 45 /// }, 45 46 /// ..Default::default() 46 47 /// }, ··· 80 81 Viewport, 81 82 } 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 + 83 96 #[derive(Clone)] 84 97 pub struct PrefetchOptions { 85 98 /// The prefetch strategy to use 86 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, 87 105 } 88 106 89 107 impl Default for PrefetchOptions { 90 108 fn default() -> Self { 91 109 Self { 92 110 strategy: PrefetchStrategy::Tap, 111 + prerender: false, 112 + eagerness: PrerenderEagerness::Immediate, 93 113 } 94 114 } 95 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 34 specifier: ^6.0.6 35 35 version: 6.0.6 36 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 + 37 46 packages: 38 47 39 48 '@jridgewell/gen-mapping@0.3.13': ··· 243 252 '@parcel/watcher@2.5.1': 244 253 resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} 245 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 246 260 247 261 '@tailwindcss/cli@4.1.18': 248 262 resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==} ··· 333 347 resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} 334 348 engines: {node: '>= 10'} 335 349 350 + '@types/node@22.19.7': 351 + resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} 352 + 336 353 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260109.1': 337 354 resolution: {integrity: sha512-rEY7JFH9JhIQ7SCjD+cpwPhIBLzNOgA7IVkfIcOpbWTmtOufx0sTZejR5B2b81x2fLCJDPZGpUv71wD1LP45iA==} 338 355 cpu: [arm64] ··· 395 412 fill-range@7.1.1: 396 413 resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} 397 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] 398 420 399 421 graceful-fs@4.2.11: 400 422 resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} ··· 525 547 resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 526 548 engines: {node: '>=8.6'} 527 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 + 528 560 source-map-js@1.2.1: 529 561 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 530 562 engines: {node: '>=0.10.0'} ··· 543 575 to-regex-range@5.0.1: 544 576 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 545 577 engines: {node: '>=8.0'} 578 + 579 + undici-types@6.21.0: 580 + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 546 581 547 582 snapshots: 548 583 ··· 691 726 '@parcel/watcher-win32-ia32': 2.5.1 692 727 '@parcel/watcher-win32-x64': 2.5.1 693 728 729 + '@playwright/test@1.58.0': 730 + dependencies: 731 + playwright: 1.58.0 732 + 694 733 '@tailwindcss/cli@4.1.18': 695 734 dependencies: 696 735 '@parcel/watcher': 2.5.1 ··· 762 801 '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 763 802 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 764 803 804 + '@types/node@22.19.7': 805 + dependencies: 806 + undici-types: 6.21.0 807 + 765 808 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260109.1': 766 809 optional: true 767 810 ··· 811 854 fill-range@7.1.1: 812 855 dependencies: 813 856 to-regex-range: 5.0.1 857 + 858 + fsevents@2.3.2: 859 + optional: true 814 860 815 861 graceful-fs@4.2.11: {} 816 862 ··· 924 970 925 971 picomatch@2.3.1: {} 926 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 + 927 981 source-map-js@1.2.1: {} 928 982 929 983 tailwindcss@4.1.18: {} ··· 935 989 to-regex-range@5.0.1: 936 990 dependencies: 937 991 is-number: 7.0.0 992 + 993 + undici-types@6.21.0: {}
+1
pnpm-workspace.yaml
··· 1 1 packages: 2 2 - crates/* 3 + - e2e 3 4 4 5 onlyBuiltDependencies: 5 6 - "@parcel/watcher"
+30 -1
website/content/docs/prefetching.md
··· 11 11 12 12 ## Configuration 13 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`. 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 15 16 16 ```rs 17 17 use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy}; ··· 19 19 BuildOptions { 20 20 prefetch: PrefetchOptions { 21 21 strategy: PrefetchStrategy::Hover, 22 + ..Default::default() 22 23 }, 23 24 ..Default.default() 24 25 } 25 26 ``` 26 27 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 1 use chrono::{Datelike, Utc}; 2 + use maud::{DOCTYPE, Markup, PreEscaped, html}; 3 3 mod docs_sidebars; 4 4 mod header; 5 5 ··· 152 152 meta name="viewport" content="width=device-width, initial-scale=1"; 153 153 (generator()) 154 154 link rel="icon" href="/favicon.svg"; 155 - (seo_data.render(&ctx.base_url)) 155 + (seo_data.render(ctx.base_url)) 156 156 } 157 157 body { 158 158 div.relative.bg-our-white {
+8 -2
xtask/src/main.rs
··· 49 49 50 50 println!("Building JavaScript for maudit-cli..."); 51 51 52 - // Ensure the dist directory exists 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 + } 53 56 fs::create_dir_all(&js_dist_dir)?; 54 57 55 58 // Configure Rolldown bundler input ··· 98 101 99 102 println!("Building JavaScript for maudit..."); 100 103 101 - // Ensure the dist directory exists 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 + } 102 108 fs::create_dir_all(&js_dist_dir)?; 103 109 104 110 // Configure Rolldown bundler input