Rust library to generate static websites

feat: prefetch support (#83)

* feat: prefetch support

* fix: match rolldown filename sanitization

* fix: some progress but honestly it's funky

* feat: implement other strategies

* chore: changeset

* fix: I will fight for your right to make Maudit at home using Maudit

* fix: some more cleanup

* fix: hmm

* fix: only keep prefetch enabled in relevant benchmarks

* fix: im stupid

* fix: adjust for feedback

authored by

Erika and committed by
GitHub
9aa0309f 2ca2542a

+616 -39
+5
.sampo/changesets/noble-lady-tuonetar.md
··· 1 + --- 2 + cargo/maudit: patch 3 + --- 4 + 5 + Fixed assets using filenames with invalid characters for URLs resulting in broken links
+7
.sampo/changesets/valiant-stormcaller-aino.md
··· 1 + --- 2 + cargo/maudit: minor 3 + --- 4 + 5 + Added support for prefetching links. By default, Maudit will now automatically prefetch links your users press on in order to increase the performance of page navigations. 6 + 7 + Other, more aggressive strategies for prefetching are also available: Hover and Viewport, which respectively prefetch links on hover and all links in the viewport.
+10 -2
benchmarks/md-benchmark/src/lib.rs
··· 1 1 use maudit::{ 2 - BuildOptions, 2 + BuildOptions, PrefetchOptions, PrefetchStrategy, 3 3 content::{UntypedMarkdownContent, glob_markdown}, 4 4 content_sources, coronate, routes, 5 5 }; ··· 9 9 let _ = coronate( 10 10 routes![page::Article], 11 11 content_sources!["articles" => glob_markdown::<UntypedMarkdownContent>(&format!("content/{}/*.md", markdown_count))], 12 - BuildOptions::default(), 12 + BuildOptions { 13 + prefetch: PrefetchOptions { 14 + // This benchmark is really about testing Maudit's Markdown rendering pipeline, if we enable prefetching then a lot of time 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 + }, 13 21 ); 14 22 }
+9 -2
benchmarks/overhead/src/lib.rs
··· 1 - use maudit::{BuildOptions, content_sources, coronate, routes}; 1 + use maudit::{BuildOptions, PrefetchOptions, PrefetchStrategy, content_sources, coronate, routes}; 2 2 mod page; 3 3 4 4 pub fn build_website() { 5 5 let _ = coronate( 6 6 routes![page::Article], 7 7 content_sources![], 8 - BuildOptions::default(), 8 + BuildOptions { 9 + prefetch: PrefetchOptions { 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 + }, 9 16 ); 10 17 }
+1 -1
crates/maudit/Cargo.toml
··· 29 29 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 30 30 glob = "0.3.1" 31 31 syntect = "5.0" 32 - lol_html = "2.1.0" 32 + lol_html = "2.7.1" 33 33 slug = "0.1.6" 34 34 image = "0.25.6" 35 35 webp = "0.3.1"
+53
crates/maudit/js/prefetch.ts
··· 1 + const preloadedUrls = new Set<string>(); 2 + 3 + interface PreloadConfig { 4 + skipConnectionCheck?: boolean; 5 + } 6 + 7 + export function prefetch(url: string, config?: PreloadConfig) { 8 + let urlObj: URL; 9 + try { 10 + urlObj = new URL(url, window.location.href); 11 + urlObj.hash = ""; 12 + } catch { 13 + throw new Error(`Invalid URL provided to prefetch: ${url}`); 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 + 33 + function canPrefetchUrl(url: URL, skipConnectionCheck: boolean): boolean { 34 + return ( 35 + navigator.onLine && // 1. Don't prefetch if the browser is offline (duh) 36 + (skipConnectionCheck || !hasLimitedBandwidth()) && // 2. Don't prefetch if the user has limited bandwidth, unless explicitely asked 37 + window.location.origin === url.origin && // 3. Don't prefetch cross-origin URLs 38 + !preloadedUrls.has(url.href) && // 4. Don't prefetch URLs we've already prefetched 39 + (window.location.pathname !== url.pathname || // 5. Don't prefetch the current page (different path or query string) 40 + window.location.search !== url.search) 41 + ); 42 + } 43 + 44 + function hasLimitedBandwidth(): boolean { 45 + // Chrome thing 46 + // https://caniuse.com/?search=navigator.connection 47 + if ("connection" in navigator) { 48 + const networkInfo = (navigator as any).connection; 49 + return networkInfo.saveData || networkInfo.effectiveType.endsWith("2g"); 50 + } 51 + 52 + return false; 53 + }
+78
crates/maudit/js/prefetch/hover.ts
··· 1 + import { prefetch } from "../prefetch.ts"; 2 + 3 + const listenedAnchors = new WeakSet<HTMLAnchorElement>(); 4 + 5 + // TODO: Make this configurable, needs rolldown_plugin_replace and stuff 6 + const observeMutations = true; 7 + 8 + function init() { 9 + let timeout: ReturnType<typeof setTimeout> | null = null; 10 + 11 + // Handle focus listeners for keyboard navigation accessibility 12 + document.body.addEventListener( 13 + "focusin", 14 + (e) => { 15 + if (e.target instanceof HTMLAnchorElement) { 16 + handleHoverIn(e); 17 + } 18 + }, 19 + { passive: true }, 20 + ); 21 + document.body.addEventListener("focusout", handleHoverOut, { passive: true }); 22 + 23 + // Attach hover listeners to all anchors 24 + const attachListeners = () => { 25 + const anchors = document.getElementsByTagName("a"); 26 + for (const anchor of anchors) { 27 + if (listenedAnchors.has(anchor)) continue; 28 + 29 + listenedAnchors.add(anchor); 30 + anchor.addEventListener("mouseenter", handleHoverIn, { passive: true }); 31 + anchor.addEventListener("mouseleave", handleHoverOut, { passive: true }); 32 + } 33 + }; 34 + 35 + function handleHoverIn(e: Event) { 36 + const target = e.target as HTMLAnchorElement; 37 + 38 + if (!target.href) { 39 + return; 40 + } 41 + 42 + if (timeout !== null) { 43 + clearTimeout(timeout); 44 + } 45 + timeout = setTimeout(() => { 46 + prefetch(target.href); 47 + timeout = null; 48 + }, 80); 49 + } 50 + 51 + function handleHoverOut() { 52 + if (timeout !== null) { 53 + clearTimeout(timeout); 54 + timeout = null; 55 + } 56 + } 57 + 58 + document.addEventListener("DOMContentLoaded", attachListeners); 59 + 60 + if (observeMutations) { 61 + // Re-attach listeners for dynamically added content 62 + const observer = new MutationObserver((mutations) => { 63 + for (const mutation of mutations) { 64 + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { 65 + attachListeners(); 66 + break; 67 + } 68 + } 69 + }); 70 + 71 + observer.observe(document.body, { 72 + childList: true, 73 + subtree: true, 74 + }); 75 + } 76 + } 77 + 78 + init();
+52
crates/maudit/js/prefetch/tap.ts
··· 1 + import { prefetch } from "../prefetch.ts"; 2 + 3 + const listenedAnchors = new WeakSet<HTMLAnchorElement>(); 4 + 5 + // TODO: Make this configurable, needs rolldown_plugin_replace and stuff 6 + const observeMutations = true; 7 + 8 + function init() { 9 + // Attach touchstart/mousedown listeners to all anchors 10 + const attachListeners = () => { 11 + const anchors = document.getElementsByTagName("a"); 12 + for (const anchor of anchors) { 13 + if (listenedAnchors.has(anchor)) continue; 14 + 15 + listenedAnchors.add(anchor); 16 + anchor.addEventListener("touchstart", handleTap, { passive: true }); 17 + anchor.addEventListener("mousedown", handleTap, { passive: true }); 18 + } 19 + }; 20 + 21 + document.addEventListener("DOMContentLoaded", attachListeners); 22 + 23 + function handleTap(e: TouchEvent | MouseEvent) { 24 + const target = e.currentTarget as HTMLAnchorElement; 25 + 26 + if (!target.href) { 27 + return; 28 + } 29 + 30 + // Prefetch on tap/mousedown 31 + prefetch(target.href); 32 + } 33 + 34 + if (observeMutations) { 35 + // Re-attach listeners for dynamically added content 36 + const observer = new MutationObserver((mutations) => { 37 + for (const mutation of mutations) { 38 + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { 39 + attachListeners(); 40 + break; 41 + } 42 + } 43 + }); 44 + 45 + observer.observe(document.body, { 46 + childList: true, 47 + subtree: true, 48 + }); 49 + } 50 + } 51 + 52 + init();
+106
crates/maudit/js/prefetch/viewport.ts
··· 1 + import { prefetch } from "../prefetch.ts"; 2 + 3 + const prefetchedAnchors = new WeakSet<HTMLAnchorElement>(); 4 + const observedAnchors = new WeakSet<HTMLAnchorElement>(); 5 + 6 + // TODO: Make this configurable, needs rolldown_plugin_replace and stuff 7 + const observeMutations = true; 8 + 9 + function init() { 10 + let intersectionObserver: IntersectionObserver | null = null; 11 + 12 + function createIntersectionObserver(): IntersectionObserver { 13 + const timeouts = new WeakMap<HTMLAnchorElement, ReturnType<typeof setTimeout>>(); 14 + 15 + return new IntersectionObserver( 16 + (entries) => { 17 + for (const entry of entries) { 18 + const anchor = entry.target as HTMLAnchorElement; 19 + const existingTimeout = timeouts.get(anchor); 20 + 21 + // Clear any pending timeout 22 + if (existingTimeout) { 23 + clearTimeout(existingTimeout); 24 + timeouts.delete(anchor); 25 + } 26 + 27 + if (entry.isIntersecting) { 28 + // Skip if already prefetched 29 + if (prefetchedAnchors.has(anchor)) { 30 + intersectionObserver?.unobserve(anchor); 31 + continue; 32 + } 33 + 34 + // Debounce by 300ms to avoid prefetching during rapid scrolling 35 + const timeout = setTimeout(() => { 36 + timeouts.delete(anchor); 37 + if (!prefetchedAnchors.has(anchor)) { 38 + prefetchedAnchors.add(anchor); 39 + prefetch(anchor.href); 40 + } 41 + intersectionObserver?.unobserve(anchor); 42 + }, 300); 43 + 44 + timeouts.set(anchor, timeout); 45 + } 46 + // If exited viewport, timeout already cleared above 47 + } 48 + }, 49 + { 50 + // Prefetch slightly before element enters viewport for smoother UX 51 + rootMargin: "50px", 52 + // Only trigger when at least 10% of the link is visible 53 + threshold: 0.1, 54 + }, 55 + ); 56 + } 57 + 58 + function observeAnchors() { 59 + intersectionObserver ??= createIntersectionObserver(); 60 + 61 + const anchors = document.getElementsByTagName("a"); 62 + for (const anchor of anchors) { 63 + // Skip if already observing or has no href 64 + if (observedAnchors.has(anchor) || !anchor.href) continue; 65 + 66 + observedAnchors.add(anchor); 67 + intersectionObserver.observe(anchor); 68 + } 69 + } 70 + 71 + // This is always in a type="module" script, so, it'll always run after the DOM is ready 72 + observeAnchors(); 73 + 74 + if (observeMutations) { 75 + // Watch for dynamically added anchors 76 + const mutationObserver = new MutationObserver((mutations) => { 77 + let hasNewAnchors = false; 78 + for (const mutation of mutations) { 79 + if (mutation.type === "childList" && mutation.addedNodes.length > 0) { 80 + // Check if any added nodes are or contain anchors 81 + for (const node of mutation.addedNodes) { 82 + if (node.nodeType === Node.ELEMENT_NODE) { 83 + const element = node as Element; 84 + if (element.tagName === "A" || element.getElementsByTagName("a").length > 0) { 85 + hasNewAnchors = true; 86 + break; 87 + } 88 + } 89 + } 90 + } 91 + if (hasNewAnchors) break; 92 + } 93 + 94 + if (hasNewAnchors) { 95 + observeAnchors(); 96 + } 97 + }); 98 + 99 + mutationObserver.observe(document.body, { 100 + childList: true, 101 + subtree: true, 102 + }); 103 + } 104 + } 105 + 106 + init();
crates/maudit/js/preload.ts

This is a binary file and will not be displayed.

+29 -6
crates/maudit/src/assets.rs
··· 8 8 9 9 mod image; 10 10 pub mod image_cache; 11 + pub mod prefetch; 12 + mod sanitize_filename; 11 13 mod script; 12 14 mod style; 13 15 mod tailwind; 14 16 pub use image::{Image, ImageFormat, ImageOptions, ImagePlaceholder, RenderWithAlt, RenderedImage}; 17 + pub use prefetch::PrefetchPlugin; 15 18 pub use script::Script; 16 19 pub use style::{Style, StyleOptions}; 17 20 pub use tailwind::TailwindPlugin; ··· 57 60 image_cache, 58 61 ..Default::default() 59 62 } 63 + } 64 + 65 + pub fn with_default_assets( 66 + assets_options: &RouteAssetsOptions, 67 + image_cache: Option<ImageCache>, 68 + scripts: Vec<Script>, 69 + styles: Vec<Style>, 70 + ) -> Self { 71 + let mut route_assets = Self::new(assets_options, image_cache); 72 + 73 + for script in scripts { 74 + route_assets.scripts.insert(script); 75 + } 76 + 77 + for style in styles { 78 + route_assets.styles.insert(style); 79 + } 80 + 81 + route_assets 60 82 } 61 83 62 84 pub fn assets(&self) -> impl Iterator<Item = &dyn Asset> { ··· 398 420 implement_asset_trait!(Script); 399 421 implement_asset_trait!(Style); 400 422 401 - struct HashConfig<'a> { 402 - asset_type: HashAssetType<'a>, 403 - hashing_strategy: &'a AssetHashingStrategy, 423 + pub struct HashConfig<'a> { 424 + pub asset_type: HashAssetType<'a>, 425 + pub hashing_strategy: &'a AssetHashingStrategy, 404 426 } 405 427 406 - enum HashAssetType<'a> { 428 + pub enum HashAssetType<'a> { 407 429 Image(&'a ImageOptions), 408 430 Style(&'a StyleOptions), 409 431 Script, ··· 411 433 412 434 fn make_filename(path: &Path, hash: &String, extension: Option<&str>) -> PathBuf { 413 435 let file_stem = path.file_stem().unwrap(); 436 + let sanitized_stem = sanitize_filename::default_sanitize_file_name(file_stem.to_str().unwrap()); 414 437 415 438 let mut filename = PathBuf::new(); 416 - filename.push(format!("{}.{}", file_stem.to_str().unwrap(), hash)); 439 + filename.push(format!("{}.{}", sanitized_stem, hash)); 417 440 418 441 if let Some(extension) = extension { 419 442 filename.set_extension(format!("{}.{}", hash, extension)); ··· 430 453 output_assets_dir.join(file_name) 431 454 } 432 455 433 - fn calculate_hash(path: &Path, options: Option<&HashConfig>) -> Result<String, AssetError> { 456 + pub fn calculate_hash(path: &Path, options: Option<&HashConfig>) -> Result<String, AssetError> { 434 457 let start_time = Instant::now(); 435 458 let content = if options 436 459 .is_some_and(|cfg| *cfg.hashing_strategy == AssetHashingStrategy::FastImprecise)
+46
crates/maudit/src/assets/prefetch.rs
··· 1 + use rolldown::plugin::{HookUsage, Plugin}; 2 + 3 + pub const PREFETCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/js/prefetch.ts"); 4 + pub const PREFETCH_HOVER_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/js/prefetch/hover.ts"); 5 + pub const PREFETCH_TAP_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/js/prefetch/tap.ts"); 6 + pub const PREFETCH_VIEWPORT_PATH: &str = 7 + concat!(env!("CARGO_MANIFEST_DIR"), "/js/prefetch/viewport.ts"); 8 + 9 + // Built paths, we don't use any of those ourselves but they can be useful if someone wants to have a bundler-less Maudit 10 + pub const PREFETCH_BUILT_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/js/dist/prefetch.js"); 11 + pub const PREFETCH_HOVER_BUILT_PATH: &str = 12 + concat!(env!("CARGO_MANIFEST_DIR"), "/js/dist/prefetch/hover.js"); 13 + pub const PREFETCH_TAP_BUILT_PATH: &str = 14 + concat!(env!("CARGO_MANIFEST_DIR"), "/js/dist/prefetch/tap.js"); 15 + pub const PREFETCH_VIEWPORT_BUILT_PATH: &str = 16 + concat!(env!("CARGO_MANIFEST_DIR"), "/js/dist/prefetch/viewport.js"); 17 + 18 + /// Rolldown plugin to handle the maudit:prefetch specifier. 19 + /// Importing the actual prefetch.ts file from Maudit's crate is very cumbersome in JS, and TypeScript anyway won't enjoy finding the types there 20 + /// As such, this plugin resolves the maudit:prefetch specifier to the actual file path of prefetch.ts in the Maudit crate for the user. 21 + #[derive(Debug)] 22 + pub struct PrefetchPlugin; 23 + 24 + impl Plugin for PrefetchPlugin { 25 + fn name(&self) -> std::borrow::Cow<'static, str> { 26 + "builtin:prefetch".into() 27 + } 28 + 29 + fn register_hook_usage(&self) -> HookUsage { 30 + HookUsage::ResolveId 31 + } 32 + 33 + async fn resolve_id( 34 + &self, 35 + _ctx: &rolldown::plugin::PluginContext, 36 + args: &rolldown::plugin::HookResolveIdArgs<'_>, 37 + ) -> rolldown::plugin::HookResolveIdReturn { 38 + if args.specifier == "maudit:prefetch" { 39 + return Ok(Some(rolldown::plugin::HookResolveIdOutput { 40 + id: PREFETCH_PATH.into(), 41 + ..Default::default() 42 + })); 43 + } 44 + Ok(None) 45 + } 46 + }
+87
crates/maudit/src/assets/sanitize_filename.rs
··· 1 + // MIT License 2 + 3 + // Copyright (c) 2024-present VoidZero Inc. & Contributors 4 + 5 + // Permission is hereby granted, free of charge, to any person obtaining a copy 6 + // of this software and associated documentation files (the "Software"), to deal 7 + // in the Software without restriction, including without limitation the rights 8 + // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + // copies of the Software, and to permit persons to whom the Software is 10 + // furnished to do so, subject to the following conditions: 11 + 12 + // The above copyright notice and this permission notice shall be included in all 13 + // copies or substantial portions of the Software. 14 + 15 + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + // SOFTWARE. 22 + 23 + macro_rules! matches_invalid_chars { 24 + ($chars:ident) => { 25 + matches!($chars, 26 + '\u{0000}' 27 + ..='\u{001f}' 28 + | '"' 29 + | '#' 30 + | '$' 31 + | '%' 32 + | '&' 33 + | '*' 34 + | '+' 35 + | ',' 36 + | ':' 37 + | ';' 38 + | '<' 39 + | '=' 40 + | '>' 41 + | '?' 42 + | '[' 43 + | ']' 44 + | '^' 45 + | '`' 46 + | '{' 47 + | '|' 48 + | '}' 49 + | '\u{007f}' 50 + ) 51 + }; 52 + } 53 + 54 + // Follow from https://github.com/rollup/rollup/blob/master/src/utils/sanitizeFileName.ts 55 + pub fn default_sanitize_file_name(str: &str) -> String { 56 + let mut sanitized = String::with_capacity(str.len()); 57 + let mut chars = str.chars(); 58 + 59 + // A `:` is only allowed as part of a windows drive letter (ex: C:\foo) 60 + // Otherwise, avoid them because they can refer to NTFS alternate data streams. 61 + if starts_with_windows_drive(str) { 62 + sanitized.push(chars.next().unwrap()); 63 + sanitized.push(chars.next().unwrap()); 64 + } 65 + 66 + for char in chars { 67 + if matches_invalid_chars!(char) { 68 + sanitized.push('_'); 69 + } else { 70 + sanitized.push(char); 71 + } 72 + } 73 + sanitized 74 + } 75 + 76 + fn starts_with_windows_drive(str: &str) -> bool { 77 + let mut chars = str.chars(); 78 + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) { 79 + return false; 80 + } 81 + chars.next().is_some_and(|c| c == ':') 82 + } 83 + 84 + #[test] 85 + fn test_sanitize_file_name() { 86 + assert_eq!(default_sanitize_file_name("\0+a=Z_0-"), "__a_Z_0-"); 87 + }
+79 -23
crates/maudit/src/build.rs
··· 10 10 11 11 use crate::{ 12 12 BuildOptions, BuildOutput, 13 - assets::{self, RouteAssets, TailwindPlugin, image_cache::ImageCache}, 14 - build::images::process_image, 13 + assets::{ 14 + self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin, 15 + calculate_hash, image_cache::ImageCache, prefetch, 16 + }, 17 + build::{images::process_image, options::PrefetchStrategy}, 15 18 content::ContentSources, 16 19 is_dev, 17 20 logging::print_title, ··· 130 133 .as_ref() 131 134 .map(|url| url.trim_end_matches('/')); 132 135 136 + let mut default_scripts = vec![]; 137 + 138 + let prefetch_path = match options.prefetch.strategy { 139 + PrefetchStrategy::None => None, 140 + PrefetchStrategy::Hover => Some(PathBuf::from(prefetch::PREFETCH_HOVER_PATH)), 141 + PrefetchStrategy::Tap => Some(PathBuf::from(prefetch::PREFETCH_TAP_PATH)), 142 + PrefetchStrategy::Viewport => Some(PathBuf::from(prefetch::PREFETCH_VIEWPORT_PATH)), 143 + }; 144 + 145 + if let Some(prefetch_path) = prefetch_path { 146 + let prefetch_script = Script::new( 147 + prefetch_path.clone(), 148 + true, 149 + calculate_hash( 150 + &prefetch_path, 151 + Some(&HashConfig { 152 + asset_type: HashAssetType::Script, 153 + hashing_strategy: &options.assets.hashing_strategy, 154 + }), 155 + )?, 156 + &route_assets_options, 157 + ); 158 + default_scripts.push(prefetch_script); 159 + } 160 + 133 161 // This is fully serial. It is somewhat trivial to make it parallel, but it currently isn't because every time I've tried to 134 162 // (uncommited, #25, #41, #46) it either made no difference or was slower. The overhead of parallelism is just too high for 135 163 // how fast most sites build. Ideally, it'd be configurable and default to serial, but I haven't found an ergonomic way to do that yet. ··· 154 182 155 183 // Static base route 156 184 if base_params.is_empty() { 157 - let mut route_assets = 158 - RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 185 + let mut route_assets = RouteAssets::with_default_assets( 186 + &route_assets_options, 187 + Some(image_cache.clone()), 188 + default_scripts.clone(), 189 + vec![], 190 + ); 159 191 160 192 let params = PageParams::default(); 161 193 let url = cached_route.url(&params); ··· 196 228 page_count += 1; 197 229 } else { 198 230 // Dynamic base route 199 - let mut route_assets = 200 - RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 231 + let mut route_assets = RouteAssets::with_default_assets( 232 + &route_assets_options, 233 + Some(image_cache.clone()), 234 + default_scripts.clone(), 235 + vec![], 236 + ); 201 237 let pages = route.get_pages(&mut DynamicRouteContext { 202 238 content: content_sources, 203 239 assets: &mut route_assets, ··· 262 298 263 299 if variant_params.is_empty() { 264 300 // Static variant 265 - let mut route_assets = 266 - RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 301 + let mut route_assets = RouteAssets::with_default_assets( 302 + &route_assets_options, 303 + Some(image_cache.clone()), 304 + default_scripts.clone(), 305 + vec![], 306 + ); 267 307 268 308 let params = PageParams::default(); 269 309 let url = cached_route.variant_url(&params, &variant_id)?; ··· 304 344 page_count += 1; 305 345 } else { 306 346 // Dynamic variant 307 - let mut route_assets = 308 - RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 347 + let mut route_assets = RouteAssets::with_default_assets( 348 + &route_assets_options, 349 + Some(image_cache.clone()), 350 + default_scripts.clone(), 351 + vec![], 352 + ); 309 353 let pages = route.get_pages(&mut DynamicRouteContext { 310 354 content: content_sources, 311 355 assets: &mut route_assets, ··· 409 453 .chain(css_inputs.into_iter()) 410 454 .collect::<Vec<InputItem>>(); 411 455 456 + debug!( 457 + target: "bundling", 458 + "Bundler inputs: {:?}", 459 + bundler_inputs 460 + .iter() 461 + .map(|input| input.import.clone()) 462 + .collect::<Vec<String>>() 463 + ); 464 + 412 465 if !bundler_inputs.is_empty() { 413 466 let mut module_types_hashmap = FxHashMap::default(); 414 467 module_types_hashmap.insert("woff".to_string(), ModuleType::Asset); ··· 427 480 module_types: Some(module_types_hashmap), 428 481 ..Default::default() 429 482 }, 430 - vec![Arc::new(TailwindPlugin { 431 - tailwind_path: options.assets.tailwind_binary_path.clone(), 432 - tailwind_entries: build_pages_styles 433 - .iter() 434 - .filter_map(|style| { 435 - if style.tailwind { 436 - Some(style.path().clone()) 437 - } else { 438 - None 439 - } 440 - }) 441 - .collect::<Vec<PathBuf>>(), 442 - })], 483 + vec![ 484 + Arc::new(TailwindPlugin { 485 + tailwind_path: options.assets.tailwind_binary_path.clone(), 486 + tailwind_entries: build_pages_styles 487 + .iter() 488 + .filter_map(|style| { 489 + if style.tailwind { 490 + Some(style.path().clone()) 491 + } else { 492 + None 493 + } 494 + }) 495 + .collect::<Vec<PathBuf>>(), 496 + }), 497 + Arc::new(PrefetchPlugin {}), 498 + ], 443 499 )?; 444 500 445 501 let _result = bundler.write().await?;
+33
crates/maudit/src/build/options.rs
··· 23 23 /// ```rust 24 24 /// use maudit::{ 25 25 /// content_sources, coronate, routes, BuildOptions, BuildOutput, AssetsOptions, 26 + /// PrefetchOptions, PrefetchStrategy, 26 27 /// }; 27 28 /// 28 29 /// fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { ··· 37 38 /// tailwind_binary_path: "./node_modules/.bin/tailwindcss".into(), 38 39 /// image_cache_dir: ".cache/maudit/images".into(), 39 40 /// ..Default::default() 41 + /// }, 42 + /// prefetch: PrefetchOptions { 43 + /// strategy: PrefetchStrategy::Viewport, 40 44 /// }, 41 45 /// ..Default::default() 42 46 /// }, ··· 58 62 59 63 pub assets: AssetsOptions, 60 64 65 + pub prefetch: PrefetchOptions, 66 + 61 67 /// Options for sitemap generation. See [`SitemapOptions`] for configuration. 62 68 pub sitemap: SitemapOptions, 69 + } 70 + 71 + #[derive(Clone, Copy, PartialEq, Eq)] 72 + pub enum PrefetchStrategy { 73 + /// No prefetching 74 + None, 75 + /// Prefetch links when users hover over them (with 80ms delay) 76 + Hover, 77 + /// Prefetch links when users click/tap on them 78 + Tap, 79 + /// Prefetch all links currently visible in the viewport 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 + } 63 95 } 64 96 65 97 impl BuildOptions { ··· 149 181 output_dir: "dist".into(), 150 182 static_dir: "static".into(), 151 183 clean_output_dir: true, 184 + prefetch: PrefetchOptions::default(), 152 185 assets: AssetsOptions::default(), 153 186 sitemap: SitemapOptions::default(), 154 187 }
+3 -1
crates/maudit/src/lib.rs
··· 15 15 16 16 // Exports for end-users 17 17 pub use build::metadata::{BuildOutput, PageOutput, StaticAssetOutput}; 18 - pub use build::options::{AssetHashingStrategy, AssetsOptions, BuildOptions}; 18 + pub use build::options::{ 19 + AssetHashingStrategy, AssetsOptions, BuildOptions, PrefetchOptions, PrefetchStrategy, 20 + }; 19 21 pub use sitemap::{ChangeFreq, SitemapOptions}; 20 22 21 23 // Re-export FxHashMap so that macro-generated code can use it without requiring users to add it as a dependency.
+1
tsconfig.json
··· 6 6 "target": "es2020", 7 7 "lib": ["dom", "dom.iterable", "es2020"], 8 8 "moduleResolution": "bundler", 9 + "allowImportingTsExtensions": true, 9 10 10 11 // Other Outputs 11 12 "sourceMap": true,
+17 -4
xtask/src/main.rs
··· 62 62 input: Some(input_items), 63 63 dir: Some(js_dist_dir.to_string_lossy().to_string()), 64 64 format: Some(rolldown::OutputFormat::Esm), 65 + platform: Some(rolldown::Platform::Browser), 65 66 minify: Some(RawMinifyOptions::Bool(true)), 66 67 ..Default::default() 67 68 }; ··· 101 102 fs::create_dir_all(&js_dist_dir)?; 102 103 103 104 // Configure Rolldown bundler input 104 - let input_items = vec![InputItem { 105 - name: Some("preload".to_string()), 106 - import: js_src_dir.join("preload.ts").to_string_lossy().to_string(), 107 - }]; 105 + let input_items = vec![ 106 + InputItem { 107 + name: Some("prefetch".to_string()), 108 + import: js_src_dir.join("prefetch.ts").to_string_lossy().to_string(), 109 + }, 110 + InputItem { 111 + name: Some("hover".to_string()), 112 + import: js_src_dir 113 + .join("prefetch") 114 + .join("hover.ts") 115 + .to_string_lossy() 116 + .to_string(), 117 + }, 118 + ]; 108 119 109 120 let bundler_options = BundlerOptions { 110 121 input: Some(input_items), 111 122 dir: Some(js_dist_dir.to_string_lossy().to_string()), 112 123 format: Some(rolldown::OutputFormat::Esm), 124 + platform: Some(rolldown::Platform::Browser), 125 + minify: Some(RawMinifyOptions::Bool(true)), 113 126 ..Default::default() 114 127 }; 115 128