Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(assets): Make assets safer to use (#72)

* feat(assets): Make assets safer to use

* fix: cleanup

* fix: unchecked methods

* fix: the initial issue I was there for in the first place lol

* nit: remove unrelated stuff

* fix: update docs

* chore: changeset

authored by

Erika and committed by
GitHub
696f653a 492953d6

+378 -185
+2 -1
.gitignore
··· 1 - /target 1 + target 2 2 dist 3 3 node_modules 4 + .cache 4 5 5 6 .DS_Store 6 7 project.tar
+5
.sampo/changesets/cantankerous-duchess-ahti.md
··· 1 + --- 2 + cargo/maudit: patch 3 + --- 4 + 5 + Fixes logging of assets during build sometimes showing inconsistent paths
+5
.sampo/changesets/valiant-lady-ilmarinen.md
··· 1 + --- 2 + cargo/maudit: patch 3 + --- 4 + 5 + Assets-related methods now all return Result, returning errors whenever files cannot be read or some other IO issue occurs. This makes it slightly more cumbersome to use, of course, however it makes it much easier to handle errors and return better error messages whenever something goes wrong.
+1
Cargo.lock
··· 2470 2470 "maudit-macros", 2471 2471 "oxc_sourcemap", 2472 2472 "oxipng", 2473 + "pathdiff", 2473 2474 "pulldown-cmark", 2474 2475 "rayon", 2475 2476 "rustc-hash",
+6 -6
benchmarks/realistic-blog/src/layout.rs
··· 1 - use maud::{Markup, PreEscaped, html}; 2 - use maudit::route::PageContext; 1 + use maud::{PreEscaped, html}; 2 + use maudit::route::{PageContext, RenderResult}; 3 3 4 - pub fn layout(ctx: &mut PageContext, content: String) -> Markup { 5 - ctx.assets.include_style("src/style.css"); 4 + pub fn layout(ctx: &mut PageContext, content: String) -> impl Into<RenderResult> { 5 + ctx.assets.include_style("src/style.css")?; 6 6 7 - html! { 7 + Ok(html! { 8 8 html { 9 9 head { 10 10 meta charset="utf-8"; ··· 23 23 } 24 24 } 25 25 } 26 - } 26 + }) 27 27 }
+1
crates/maudit/Cargo.toml
··· 47 47 oxc_sourcemap = "4.1.0" 48 48 rayon = "1.11.0" 49 49 xxhash-rust = "0.8.15" 50 + pathdiff = "0.2.3"
+229 -112
crates/maudit/src/assets.rs
··· 16 16 pub use tailwind::TailwindPlugin; 17 17 18 18 use crate::assets::image_cache::ImageCache; 19 + use crate::errors::AssetError; 19 20 use crate::{AssetHashingStrategy, BuildOptions}; 20 21 21 22 #[derive(Default)] ··· 57 58 } 58 59 } 59 60 60 - /// Get a placeholder for an image using the cache if available 61 - pub fn get_image_placeholder( 62 - &self, 63 - image_path: &Path, 64 - ) -> Option<crate::assets::image::ImagePlaceholder> { 65 - if let Some(cache) = &self.image_cache { 66 - use base64::Engine; 67 - use log::debug; 68 - 69 - if let Some(cached) = cache.get_placeholder(image_path) { 70 - debug!("Using cached placeholder for {}", image_path.display()); 71 - let thumbhash_base64 = 72 - base64::engine::general_purpose::STANDARD.encode(&cached.thumbhash); 73 - return Some(crate::assets::image::ImagePlaceholder::new( 74 - cached.thumbhash, 75 - thumbhash_base64, 76 - )); 77 - } 78 - } 79 - None 80 - } 81 - 82 - /// Cache a placeholder for an image 83 - pub fn cache_image_placeholder(&self, image_path: &Path, thumbhash: Vec<u8>) { 84 - if let Some(cache) = &self.image_cache { 85 - cache.cache_placeholder(image_path, thumbhash); 86 - } 87 - } 88 - 89 61 pub fn assets(&self) -> impl Iterator<Item = &dyn Asset> { 90 62 self.images 91 63 .iter() ··· 107 79 /// Add an image to the page assets, causing the file to be created in the output directory. The image is resolved relative to the current working directory. 108 80 /// 109 81 /// The image will not automatically be included in the page, but can be included through the `.url()` method on the returned `Image` object. 110 - pub fn add_image_with_options<P>(&mut self, image_path: P, options: ImageOptions) -> Image 82 + pub fn add_image_with_options<P>( 83 + &mut self, 84 + image_path: P, 85 + options: ImageOptions, 86 + ) -> Result<Image, AssetError> 111 87 where 112 88 P: Into<PathBuf>, 113 89 { 114 90 let image_path = image_path.into(); 91 + let image_path = 92 + fs::canonicalize(&image_path).map_err(|e| AssetError::CanonicalizeFailed { 93 + path: image_path.clone(), 94 + source: e, 95 + })?; 115 96 116 97 let image_options = if options == ImageOptions::default() { 117 98 None ··· 127 108 ), 128 109 hashing_strategy: &self.options.hashing_strategy, 129 110 }), 130 - ); 111 + )?; 131 112 132 113 let image = Image::new( 133 114 image_path, ··· 139 120 140 121 self.images.insert(image.clone()); 141 122 142 - image 123 + Ok(image) 143 124 } 144 125 145 - pub fn add_image<P>(&mut self, image_path: P) -> Image 126 + pub fn add_image<P>(&mut self, image_path: P) -> Result<Image, AssetError> 146 127 where 147 128 P: Into<PathBuf>, 148 129 { 149 130 self.add_image_with_options(image_path, ImageOptions::default()) 150 131 } 151 132 133 + /// Add an image to the page assets, panicking on error. See [RouteAssets::add_image_with_options] for details. 134 + pub fn add_image_with_options_unchecked<P>( 135 + &mut self, 136 + image_path: P, 137 + options: ImageOptions, 138 + ) -> Image 139 + where 140 + P: Into<PathBuf>, 141 + { 142 + self.add_image_with_options(image_path, options) 143 + .expect("Failed to add image") 144 + } 145 + 146 + /// Add an image to the page assets, panicking on error. See [RouteAssets::add_image] for details. 147 + pub fn add_image_unchecked<P>(&mut self, image_path: P) -> Image 148 + where 149 + P: Into<PathBuf>, 150 + { 151 + self.add_image(image_path).expect("Failed to add image") 152 + } 153 + 152 154 /// Add a script to the page assets, causing the file to be created in the output directory. The script is resolved relative to the current working directory. 153 155 /// 154 156 /// The script will not automatically be included in the page, but can be included through the `.url()` method on the returned `Script` object. 155 157 /// Alternatively, a script can be included automatically using the [RouteAssets::include_script] method instead. 156 - pub fn add_script<P>(&mut self, script_path: P) -> Script 158 + pub fn add_script<P>(&mut self, script_path: P) -> Result<Script, AssetError> 157 159 where 158 160 P: Into<PathBuf>, 159 161 { 160 162 let script_path = script_path.into(); 163 + let script_path = 164 + fs::canonicalize(&script_path).map_err(|e| AssetError::CanonicalizeFailed { 165 + path: script_path.clone(), 166 + source: e, 167 + })?; 161 168 162 169 let hash = calculate_hash( 163 170 &script_path, ··· 165 172 asset_type: HashAssetType::Script, 166 173 hashing_strategy: &self.options.hashing_strategy, 167 174 }), 168 - ); 175 + )?; 169 176 170 177 let script = Script::new(script_path, false, hash, &self.options); 171 178 172 179 self.scripts.insert(script.clone()); 173 180 174 - script 181 + Ok(script) 182 + } 183 + 184 + /// Add a script to the page assets, panicking on error. See [RouteAssets::add_script] for details. 185 + pub fn add_script_unchecked<P>(&mut self, script_path: P) -> Script 186 + where 187 + P: Into<PathBuf>, 188 + { 189 + self.add_script(script_path).expect("Failed to add script") 175 190 } 176 191 177 192 /// Include a script in the page. The script is resolved relative to the current working directory. ··· 179 194 /// This method will automatically include the script in the `<head>` of the page, if it exists. If the page does not include a `<head>` tag, at this time this method will silently fail. 180 195 /// 181 196 /// Subsequent calls to this function using the same path will result in the same script being included multiple times. 182 - pub fn include_script<P>(&mut self, script_path: P) 197 + pub fn include_script<P>(&mut self, script_path: P) -> Result<(), AssetError> 183 198 where 184 199 P: Into<PathBuf>, 185 200 { 186 201 let script_path = script_path.into(); 202 + let script_path = 203 + fs::canonicalize(&script_path).map_err(|e| AssetError::CanonicalizeFailed { 204 + path: script_path.clone(), 205 + source: e, 206 + })?; 187 207 188 208 let hash = calculate_hash( 189 209 &script_path, ··· 191 211 asset_type: HashAssetType::Script, 192 212 hashing_strategy: &self.options.hashing_strategy, 193 213 }), 194 - ); 214 + )?; 195 215 196 216 let script = Script::new(script_path, true, hash, &self.options); 197 217 198 218 self.scripts.insert(script); 219 + 220 + Ok(()) 221 + } 222 + 223 + /// Include a script in the page, panicking on error. See [RouteAssets::include_script] for details. 224 + pub fn include_script_unchecked<P>(&mut self, script_path: P) 225 + where 226 + P: Into<PathBuf>, 227 + { 228 + self.include_script(script_path) 229 + .expect("Failed to include script") 199 230 } 200 231 201 232 /// Add a style to the page assets, causing the file to be created in the output directory. The style is resolved relative to the current working directory. ··· 204 235 /// Alternatively, a style can be included automatically using the [RouteAssets::include_style] method instead. 205 236 /// 206 237 /// Subsequent calls to this method using the same path will return the same style, as such, the value returned by this method can be cloned and used multiple times without issue. This method is equivalent to calling `add_style_with_options` with the default `StyleOptions` and is purely provided for convenience. 207 - pub fn add_style<P>(&mut self, style_path: P) -> Style 238 + pub fn add_style<P>(&mut self, style_path: P) -> Result<Style, AssetError> 208 239 where 209 240 P: Into<PathBuf>, 210 241 { 211 242 self.add_style_with_options(style_path, StyleOptions::default()) 212 243 } 213 244 245 + /// Add a style to the page assets, panicking on error. See [RouteAssets::add_style] for details. 246 + pub fn add_style_unchecked<P>(&mut self, style_path: P) -> Style 247 + where 248 + P: Into<PathBuf>, 249 + { 250 + self.add_style(style_path).expect("Failed to add style") 251 + } 252 + 214 253 /// Add a style to the page assets, causing the file to be created in the output directory. The style is resolved relative to the current working directory. 215 254 /// 216 255 /// The style will not automatically be included in the page, but can be included through the `.url()` method on the returned `Style` object. 217 256 /// 218 257 /// Subsequent calls to this method using the same path will return the same style, as such, the value returned by this method can be cloned and used multiple times without issue. 219 - pub fn add_style_with_options<P>(&mut self, style_path: P, options: StyleOptions) -> Style 258 + pub fn add_style_with_options<P>( 259 + &mut self, 260 + style_path: P, 261 + options: StyleOptions, 262 + ) -> Result<Style, AssetError> 220 263 where 221 264 P: Into<PathBuf>, 222 265 { 223 266 let style_path = style_path.into(); 267 + let style_path = 268 + fs::canonicalize(&style_path).map_err(|e| AssetError::CanonicalizeFailed { 269 + path: style_path.clone(), 270 + source: e, 271 + })?; 224 272 225 273 let hash = calculate_hash( 226 274 &style_path, ··· 228 276 asset_type: HashAssetType::Style(&options), 229 277 hashing_strategy: &self.options.hashing_strategy, 230 278 }), 231 - ); 279 + )?; 232 280 233 281 let style = Style::new(style_path, false, &options, hash, &self.options); 234 282 235 283 self.styles.insert(style.clone()); 236 284 237 - style 285 + Ok(style) 286 + } 287 + 288 + /// Add a style with options to the page assets, panicking on error. See [RouteAssets::add_style_with_options] for details. 289 + pub fn add_style_with_options_unchecked<P>( 290 + &mut self, 291 + style_path: P, 292 + options: StyleOptions, 293 + ) -> Style 294 + where 295 + P: Into<PathBuf>, 296 + { 297 + self.add_style_with_options(style_path, options) 298 + .expect("Failed to add style") 238 299 } 239 300 240 301 /// Include a style in the page ··· 242 303 /// This method will automatically include the style in the `<head>` of the page, if it exists. If the page does not include a `<head>` tag, at this time this method will silently fail. 243 304 /// 244 305 /// Subsequent calls to this method using the same path will result in the same style being included multiple times. This method is equivalent to calling `include_style_with_options` with the default `StyleOptions` and is purely provided for convenience. 245 - pub fn include_style<P>(&mut self, style_path: P) 306 + pub fn include_style<P>(&mut self, style_path: P) -> Result<(), AssetError> 246 307 where 247 308 P: Into<PathBuf>, 248 309 { 249 310 self.include_style_with_options(style_path, StyleOptions::default()) 250 311 } 251 312 313 + /// Include a style in the page, panicking on error. See [RouteAssets::include_style] for details. 314 + pub fn include_style_unchecked<P>(&mut self, style_path: P) 315 + where 316 + P: Into<PathBuf>, 317 + { 318 + self.include_style(style_path) 319 + .expect("Failed to include style") 320 + } 321 + 252 322 /// Include a style in the page 253 323 /// 254 324 /// This method will automatically include the style in the `<head>` of the page, if it exists. If the page does not include a `<head>` tag, at this time this method will silently fail. 255 325 /// 256 326 /// Subsequent calls to this method using the same path will result in the same style being included multiple times. 257 - pub fn include_style_with_options<P>(&mut self, style_path: P, options: StyleOptions) 327 + pub fn include_style_with_options<P>( 328 + &mut self, 329 + style_path: P, 330 + options: StyleOptions, 331 + ) -> Result<(), AssetError> 258 332 where 259 333 P: Into<PathBuf>, 260 334 { 261 335 let style_path = style_path.into(); 336 + let style_path = 337 + fs::canonicalize(&style_path).map_err(|e| AssetError::CanonicalizeFailed { 338 + path: style_path.clone(), 339 + source: e, 340 + })?; 262 341 263 342 let hash = calculate_hash( 264 343 &style_path, ··· 266 345 asset_type: HashAssetType::Style(&options), 267 346 hashing_strategy: &self.options.hashing_strategy, 268 347 }), 269 - ); 348 + )?; 270 349 271 350 let style = Style::new(style_path, true, &options, hash, &self.options); 272 351 273 352 self.styles.insert(style); 353 + 354 + Ok(()) 355 + } 356 + 357 + /// Include a style with options in the page, panicking on error. See [RouteAssets::include_style_with_options] for details. 358 + pub fn include_style_with_options_unchecked<P>(&mut self, style_path: P, options: StyleOptions) 359 + where 360 + P: Into<PathBuf>, 361 + { 362 + self.include_style_with_options(style_path, options) 363 + .expect("Failed to include style") 274 364 } 275 365 } 276 366 ··· 339 429 output_assets_dir.join(file_name) 340 430 } 341 431 342 - fn calculate_hash(path: &Path, options: Option<&HashConfig>) -> String { 432 + fn calculate_hash(path: &Path, options: Option<&HashConfig>) -> Result<String, AssetError> { 343 433 let start_time = Instant::now(); 344 434 let content = if options 345 435 .is_some_and(|cfg| *cfg.hashing_strategy == AssetHashingStrategy::FastImprecise) 346 436 { 347 - let metadata = fs::metadata(path).unwrap(); 437 + let metadata = fs::metadata(path).map_err(|e| AssetError::MetadataFailed { 438 + path: path.to_path_buf(), 439 + source: e, 440 + })?; 348 441 349 442 let mut buf = Vec::with_capacity(16); 350 443 buf.extend_from_slice( 351 444 &metadata 352 445 .modified() 353 - .unwrap() 446 + .map_err(|e| AssetError::MetadataFailed { 447 + path: path.to_path_buf(), 448 + source: e, 449 + })? 354 450 .duration_since(std::time::UNIX_EPOCH) 355 - .unwrap() 451 + .map_err(|e| AssetError::MetadataFailed { 452 + path: path.to_path_buf(), 453 + source: std::io::Error::other(e), 454 + })? 356 455 .as_secs() 357 456 .to_le_bytes(), 358 457 ); ··· 361 460 362 461 buf 363 462 } else { 364 - fs::read(path).unwrap_or_else(|_| panic!("Failed to read asset file: {:?}", path)) 463 + fs::read(path).map_err(|e| AssetError::ReadFailed { 464 + path: path.to_path_buf(), 465 + source: e, 466 + })? 365 467 }; 366 468 367 469 // Pre-allocate a single buffer to hash at once ··· 399 501 400 502 // TODO: This works, but perhaps we can generate prettier hashes, see https://github.com/rolldown/rolldown/blob/abf62c45d7a69b42dab4bff92095e320b418e9b8/crates/rolldown_utils/src/xxhash.rs 401 503 let hex = format!("{:016x}", hash); 402 - hex[..5].to_string() 504 + Ok(hex[..5].to_string()) 403 505 } 404 506 405 507 #[cfg(test)] ··· 422 524 fn test_add_style() { 423 525 let temp_dir = setup_temp_dir(); 424 526 let mut page_assets = RouteAssets::default(); 425 - page_assets.add_style(temp_dir.join("style.css")); 527 + page_assets.add_style(temp_dir.join("style.css")).unwrap(); 426 528 427 529 assert!(page_assets.styles.len() == 1); 428 530 } ··· 432 534 let temp_dir = setup_temp_dir(); 433 535 let mut page_assets = RouteAssets::default(); 434 536 435 - page_assets.include_style(temp_dir.join("style.css")); 537 + page_assets 538 + .include_style(temp_dir.join("style.css")) 539 + .unwrap(); 436 540 437 541 assert!(page_assets.styles.len() == 1); 438 542 assert!(page_assets.included_styles().count() == 1); ··· 443 547 let temp_dir = setup_temp_dir(); 444 548 let mut page_assets = RouteAssets::default(); 445 549 446 - page_assets.add_script(temp_dir.join("script.js")); 550 + page_assets.add_script(temp_dir.join("script.js")).unwrap(); 447 551 assert!(page_assets.scripts.len() == 1); 448 552 } 449 553 ··· 452 556 let temp_dir = setup_temp_dir(); 453 557 let mut page_assets = RouteAssets::default(); 454 558 455 - page_assets.include_script(temp_dir.join("script.js")); 559 + page_assets 560 + .include_script(temp_dir.join("script.js")) 561 + .unwrap(); 456 562 457 563 assert!(page_assets.scripts.len() == 1); 458 564 assert!(page_assets.included_scripts().count() == 1); ··· 463 569 let temp_dir = setup_temp_dir(); 464 570 let mut page_assets = RouteAssets::default(); 465 571 466 - page_assets.add_image(temp_dir.join("image.png")); 572 + page_assets.add_image(temp_dir.join("image.png")).unwrap(); 467 573 assert!(page_assets.images.len() == 1); 468 574 } 469 575 ··· 472 578 let temp_dir = setup_temp_dir(); 473 579 let mut page_assets = RouteAssets::default(); 474 580 475 - let image = page_assets.add_image(temp_dir.join("image.png")); 581 + let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 476 582 assert_eq!(image.url().chars().next(), Some('/')); 477 583 478 - let script = page_assets.add_script(temp_dir.join("script.js")); 584 + let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 479 585 assert_eq!(script.url().chars().next(), Some('/')); 480 586 481 - let style = page_assets.add_style(temp_dir.join("style.css")); 587 + let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 482 588 assert_eq!(style.url().chars().next(), Some('/')); 483 589 } 484 590 ··· 487 593 let temp_dir = setup_temp_dir(); 488 594 let mut page_assets = RouteAssets::default(); 489 595 490 - let image = page_assets.add_image(temp_dir.join("image.png")); 596 + let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 491 597 assert!(image.url().contains(&image.hash)); 492 598 493 - let script = page_assets.add_script(temp_dir.join("script.js")); 599 + let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 494 600 assert!(script.url().contains(&script.hash)); 495 601 496 - let style = page_assets.add_style(temp_dir.join("style.css")); 602 + let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 497 603 assert!(style.url().contains(&style.hash)); 498 604 } 499 605 ··· 502 608 let temp_dir = setup_temp_dir(); 503 609 let mut page_assets = RouteAssets::default(); 504 610 505 - let image = page_assets.add_image(temp_dir.join("image.png")); 611 + let image = page_assets.add_image(temp_dir.join("image.png")).unwrap(); 506 612 assert!(image.build_path().to_string_lossy().contains(&image.hash)); 507 613 508 - let script = page_assets.add_script(temp_dir.join("script.js")); 614 + let script = page_assets.add_script(temp_dir.join("script.js")).unwrap(); 509 615 assert!(script.build_path().to_string_lossy().contains(&script.hash)); 510 616 511 - let style = page_assets.add_style(temp_dir.join("style.css")); 617 + let style = page_assets.add_style(temp_dir.join("style.css")).unwrap(); 512 618 assert!(style.build_path().to_string_lossy().contains(&style.hash)); 513 619 } 514 620 ··· 530 636 let mut page_assets = RouteAssets::default(); 531 637 532 638 // Test that different options produce different hashes 533 - let image_default = page_assets.add_image(&image_path); 534 - let image_webp = page_assets.add_image_with_options( 535 - &image_path, 536 - ImageOptions { 537 - format: Some(ImageFormat::WebP), 538 - ..Default::default() 539 - }, 540 - ); 541 - let image_resized = page_assets.add_image_with_options( 542 - &image_path, 543 - ImageOptions { 544 - width: Some(100), 545 - height: Some(100), 546 - ..Default::default() 547 - }, 548 - ); 549 - let image_combined = page_assets.add_image_with_options( 550 - &image_path, 551 - ImageOptions { 552 - width: Some(100), 553 - height: Some(100), 554 - format: Some(ImageFormat::WebP), 555 - }, 556 - ); 639 + let image_default = page_assets.add_image(&image_path).unwrap(); 640 + let image_webp = page_assets 641 + .add_image_with_options( 642 + &image_path, 643 + ImageOptions { 644 + format: Some(ImageFormat::WebP), 645 + ..Default::default() 646 + }, 647 + ) 648 + .unwrap(); 649 + let image_resized = page_assets 650 + .add_image_with_options( 651 + &image_path, 652 + ImageOptions { 653 + width: Some(100), 654 + height: Some(100), 655 + ..Default::default() 656 + }, 657 + ) 658 + .unwrap(); 659 + let image_combined = page_assets 660 + .add_image_with_options( 661 + &image_path, 662 + ImageOptions { 663 + width: Some(100), 664 + height: Some(100), 665 + format: Some(ImageFormat::WebP), 666 + }, 667 + ) 668 + .unwrap(); 557 669 558 670 // All hashes should be different 559 671 let hashes = [ ··· 593 705 let mut page_assets = RouteAssets::default(); 594 706 595 707 // Same options should produce same hash 596 - let image1 = page_assets.add_image_with_options( 597 - &image_path, 598 - ImageOptions { 599 - width: Some(200), 600 - height: Some(150), 601 - format: Some(ImageFormat::Jpeg), 602 - }, 603 - ); 708 + let image1 = page_assets 709 + .add_image_with_options( 710 + &image_path, 711 + ImageOptions { 712 + width: Some(200), 713 + height: Some(150), 714 + format: Some(ImageFormat::Jpeg), 715 + }, 716 + ) 717 + .unwrap(); 604 718 605 - let image2 = page_assets.add_image_with_options( 606 - &image_path, 607 - ImageOptions { 608 - width: Some(200), 609 - height: Some(150), 610 - format: Some(ImageFormat::Jpeg), 611 - }, 612 - ); 719 + let image2 = page_assets 720 + .add_image_with_options( 721 + &image_path, 722 + ImageOptions { 723 + width: Some(200), 724 + height: Some(150), 725 + format: Some(ImageFormat::Jpeg), 726 + }, 727 + ) 728 + .unwrap(); 613 729 614 730 assert_eq!( 615 731 image1.hash, image2.hash, ··· 625 741 let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 626 742 627 743 // Test that different tailwind options produce different hashes 628 - let style_default = page_assets.add_style(&style_path); 629 - let style_tailwind = 630 - page_assets.add_style_with_options(&style_path, StyleOptions { tailwind: true }); 744 + let style_default = page_assets.add_style(&style_path).unwrap(); 745 + let style_tailwind = page_assets 746 + .add_style_with_options(&style_path, StyleOptions { tailwind: true }) 747 + .unwrap(); 631 748 632 749 assert_ne!( 633 750 style_default.hash, style_tailwind.hash, ··· 649 766 650 767 let mut page_assets = RouteAssets::new(&RouteAssetsOptions::default(), None); 651 768 652 - let style1 = page_assets.add_style(&style1_path); 653 - let style2 = page_assets.add_style(&style2_path); 769 + let style1 = page_assets.add_style(&style1_path).unwrap(); 770 + let style2 = page_assets.add_style(&style2_path).unwrap(); 654 771 655 772 assert_ne!( 656 773 style1.hash, style2.hash, ··· 668 785 669 786 // Write first content and get hash 670 787 std::fs::write(&style_path, "body { background: red; }").unwrap(); 671 - let style1 = page_assets.add_style(&style_path); 788 + let style1 = page_assets.add_style(&style_path).unwrap(); 672 789 let hash1 = style1.hash; 673 790 674 791 // Write different content and get new hash 675 792 std::fs::write(&style_path, "body { background: green; }").unwrap(); 676 - let style2 = page_assets.add_style(&style_path); 793 + let style2 = page_assets.add_style(&style_path).unwrap(); 677 794 let hash2 = style2.hash; 678 795 679 796 assert_ne!(
+2 -2
crates/maudit/src/assets/image.rs
··· 72 72 /// 73 73 /// impl Route for ExampleRoute { 74 74 /// fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 75 - /// let image = ctx.assets.add_image("path/to/image.png"); 75 + /// let image = ctx.assets.add_image("path/to/image.png")?; 76 76 /// 77 - /// format!("<img src=\"{}\" alt=\"Example Image\" />", image.url()) 77 + /// Ok(format!("<img src=\"{}\" alt=\"Example Image\" />", image.url())) 78 78 /// } 79 79 /// } 80 80 /// ```
+5 -1
crates/maudit/src/build.rs
··· 22 22 }; 23 23 use colored::{ColoredString, Colorize}; 24 24 use log::{debug, info, trace, warn}; 25 + use pathdiff::diff_paths; 25 26 use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType}; 26 27 use rustc_hash::{FxHashMap, FxHashSet}; 27 28 ··· 344 345 ) 345 346 }); 346 347 } 347 - info!(target: "assets", "{} -> {} {}", image.path().to_string_lossy(), dest_path.to_string_lossy().dimmed(), format_elapsed_time(start_process.elapsed(), &route_format_options).dimmed()); 348 + // Make path relative to cwd for logging 349 + let image_cwd_relative = diff_paths(image.path(), env::current_dir().unwrap()) 350 + .unwrap_or_else(|| image.path().to_path_buf()); 351 + info!(target: "assets", "{} -> {} {}", image_cwd_relative.to_string_lossy(), dest_path.to_string_lossy().dimmed(), format_elapsed_time(start_process.elapsed(), &route_format_options).dimmed()); 348 352 }); 349 353 350 354 info!(target: "assets", "{}", format!("Images processed in {}", format_elapsed_time(start_time.elapsed(), &section_format_options)).bold());
+2 -1
crates/maudit/src/content/markdown.rs
··· 424 424 let resolved = parent.join(dest_url.to_string()); 425 425 route_ctx 426 426 .as_mut() 427 - .map(|ctx| ctx.assets.add_image(resolved).url().clone()) 427 + .and_then(|ctx| ctx.assets.add_image(resolved).ok()) 428 + .map(|image| image.url().clone()) 428 429 }) 429 430 .map(|image_url| { 430 431 Event::Start(Tag::Image {
+39 -1
crates/maudit/src/errors.rs
··· 1 1 //! Error types for Maudit. 2 2 use std::fmt::{self, Debug, Formatter}; 3 + use std::path::PathBuf; 3 4 use thiserror::Error; 4 5 5 6 macro_rules! impl_debug_for_error { ··· 32 33 InvalidRenderResult { route: String }, 33 34 } 34 35 35 - impl_debug_for_error!(UrlError, BuildError); 36 + #[derive(Error)] 37 + pub enum AssetError { 38 + #[error("Failed to read asset file: {path}")] 39 + ReadFailed { 40 + path: PathBuf, 41 + #[source] 42 + source: std::io::Error, 43 + }, 44 + #[error("Failed to get metadata for asset file: {path}")] 45 + MetadataFailed { 46 + path: PathBuf, 47 + #[source] 48 + source: std::io::Error, 49 + }, 50 + #[error("Failed to canonicalize asset path: {path}")] 51 + CanonicalizeFailed { 52 + path: PathBuf, 53 + #[source] 54 + source: std::io::Error, 55 + }, 56 + } 57 + 58 + #[derive(Error, Debug)] 59 + pub enum MauditError { 60 + #[error(transparent)] 61 + Asset(#[from] AssetError), 62 + 63 + #[error(transparent)] 64 + Url(#[from] UrlError), 65 + 66 + #[error(transparent)] 67 + Build(#[from] BuildError), 68 + 69 + #[error(transparent)] 70 + Io(#[from] std::io::Error), 71 + } 72 + 73 + impl_debug_for_error!(UrlError, BuildError, AssetError);
+3 -3
crates/maudit/src/route.rs
··· 251 251 /// 252 252 /// impl Route for Index { 253 253 /// fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 254 - /// let logo = ctx.assets.add_image("logo.png"); 254 + /// let logo = ctx.assets.add_image("logo.png")?; 255 255 /// let last_entries = &ctx.content.get_source::<ArticleContent>("articles").entries; 256 256 /// 257 - /// html! { 257 + /// Ok(html! { 258 258 /// main { 259 259 /// (logo.render("Maudit logo, a crudely drawn crown")) 260 260 /// ul { ··· 263 263 /// } 264 264 /// } 265 265 /// } 266 - /// } 266 + /// }) 267 267 /// } 268 268 /// } 269 269 pub struct PageContext<'a> {
+3 -3
examples/basics/src/routes/index.rs
··· 7 7 8 8 impl Route for Index { 9 9 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 - let logo = ctx.assets.add_image("images/logo.svg"); 10 + let logo = ctx.assets.add_image("images/logo.svg")?; 11 11 12 - layout(html! { 12 + Ok(layout(html! { 13 13 (logo.render("Maudit logo, a crudely drawn crown")) 14 14 h1 { "Hello World" } 15 - }) 15 + })) 16 16 } 17 17 }
+4 -4
examples/image-processing/src/routes/index.rs
··· 7 7 8 8 impl Route for Index { 9 9 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 - let logo = ctx.assets.add_image("images/logo.svg"); 10 + let logo = ctx.assets.add_image("images/logo.svg")?; 11 11 let walrus = ctx.assets.add_image_with_options( 12 12 "images/walrus.jpg", 13 13 ImageOptions { ··· 15 15 height: Some(200), 16 16 format: Some(maudit::assets::ImageFormat::WebP), 17 17 }, 18 - ); 18 + )?; 19 19 20 - layout(html! { 20 + Ok(layout(html! { 21 21 (logo.render("Maudit logo, a crudely drawn crown")) 22 22 h1 { "Hello World" } 23 23 h2 { "Here's a 200x200 walrus:" } 24 24 (walrus.render("A walrus with tusks")) 25 - }) 25 + })) 26 26 } 27 27 }
+4 -4
examples/kitchen-sink/src/routes/dynamic.rs
··· 19 19 20 20 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 21 21 let params = ctx.params::<Params>(); 22 - let image = ctx.assets.add_image("data/social-card.png"); 22 + let image = ctx.assets.add_image("data/social-card.png")?; 23 23 ctx.assets 24 - .include_style_with_options("data/tailwind.css", StyleOptions { tailwind: true }); 24 + .include_style_with_options("data/tailwind.css", StyleOptions { tailwind: true })?; 25 25 26 - html! { 26 + Ok(html! { 27 27 head { 28 28 title { "Index" } 29 29 } 30 30 h1 { "Hello, world!" } 31 31 (image.render("Maudit social card, a crudely drawn crown")) 32 32 p { (params.page) } 33 - } 33 + }) 34 34 } 35 35 }
+5 -5
examples/kitchen-sink/src/routes/endpoint.rs
··· 5 5 6 6 impl Route for Endpoint { 7 7 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 8 - let image = ctx.assets.add_image("data/logo.svg"); 9 - let some_script = ctx.assets.add_script("data/script.js"); 8 + let image = ctx.assets.add_image("data/logo.svg")?; 9 + let some_script = ctx.assets.add_script("data/script.js")?; 10 10 ctx.assets 11 - .include_style_with_options("data/tailwind.css", StyleOptions { tailwind: true }); 11 + .include_style_with_options("data/tailwind.css", StyleOptions { tailwind: true })?; 12 12 13 13 // Return some JSON 14 - format!( 14 + Ok(format!( 15 15 r#"{{ 16 16 "image": "{}", 17 17 "script": "{}" 18 18 }}"#, 19 19 image.path.to_string_lossy(), 20 20 some_script.path.to_string_lossy() 21 - ) 21 + )) 22 22 } 23 23 }
+5 -5
examples/kitchen-sink/src/routes/index.rs
··· 9 9 10 10 impl Route for Index { 11 11 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 12 - let image = ctx.assets.add_image("data/logo.svg"); 13 - let script = ctx.assets.add_script("data/some_other_script.js"); 12 + let image = ctx.assets.add_image("data/logo.svg")?; 13 + let script = ctx.assets.add_script("data/some_other_script.js")?; 14 14 let style = ctx 15 15 .assets 16 - .add_style_with_options("data/tailwind.css", StyleOptions { tailwind: true }); 16 + .add_style_with_options("data/tailwind.css", StyleOptions { tailwind: true })?; 17 17 18 18 let link_to_first_dynamic = DynamicExample.url(DynamicExampleParams { page: 1 }); 19 19 20 - html! { 20 + Ok(html! { 21 21 head { 22 22 title { "Index" } 23 23 link rel="stylesheet" href=(style.url()) {} ··· 27 27 script src=(script.url()) {} 28 28 (image.render("Maudit logo, a crudely drawn crown")) 29 29 a."text-red-500" href=(link_to_first_dynamic) { "Go to first dynamic page" } 30 - } 30 + }) 31 31 } 32 32 }
+2 -2
examples/library/src/routes/index.rs
··· 13 13 impl Route for Index { 14 14 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 15 15 let articles = ctx.content.get_source::<ArticleContent>("articles"); 16 - let logo = ctx.assets.add_image("images/logo.svg"); 16 + let logo = ctx.assets.add_image("images/logo.svg")?; 17 17 18 18 let markup = html! { 19 19 (logo.render("Maudit logo, a crudely drawn crown")) ··· 30 30 } 31 31 .into_string(); 32 32 33 - layout(markup) 33 + Ok(layout(markup)) 34 34 } 35 35 }
+3 -3
examples/oubli-basics/src/routes/index.rs
··· 7 7 8 8 impl Route for Index { 9 9 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 10 - let logo = ctx.assets.add_image("images/logo.svg"); 10 + let logo = ctx.assets.add_image("images/logo.svg")?; 11 11 12 12 let archetype_store = ctx 13 13 .content 14 14 .get_source::<oubli::ArchetypeStoreEntry>("archetype_store"); 15 15 16 - layout(html! { 16 + Ok(layout(html! { 17 17 (logo.render("Maudit logo, a crudely drawn crown")) 18 18 h1 { "Hello World" } 19 19 @for archetype in &archetype_store.entries { 20 20 a href=(archetype.id) { (archetype.data(ctx).title) } 21 21 } 22 - }) 22 + })) 23 23 } 24 24 }
+9 -7
website/content/docs/images.md
··· 12 12 13 13 ### In pages 14 14 15 - To use an image in a page, add it anywhere in your project's directory, and use the [`ctx.assets.add_image()`](https://docs.rs/maudit/latest/maudit/assets/struct.RouteAssets.html#method.add_image) method to add it to a page's assets. 15 + To use an image in a page, add it anywhere in your project's directory, and use the [`ctx.assets.add_image()`](https://docs.rs/maudit/latest/maudit/assets/struct.RouteAssets.html#method.add_image) method to add it to a page's assets. This function returns a Result containing an [Image](https://docs.rs/maudit/latest/maudit/assets/struct.Image.html) instance, which contains information about the image, such as its URL, dimensions, and a method to render an img tag. 16 + 17 + This function will error if the image file does not exist, or cannot be read for any reason. If you'd rather not deal with errors, you can use the `add_image_unchecked()` method, which will instead panic on failure. 16 18 17 19 ```rs 18 20 use maudit::route::prelude::*; ··· 23 25 24 26 impl Route for Blog { 25 27 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 26 - let image = ctx.assets.add_image("logo.png"); 28 + let image = ctx.assets.add_image("logo.png")?; 27 29 28 30 let (width, height) = image.dimensions(); 29 31 format!("<img src=\"{}\" alt=\"My logo\" width=\"{}\" height=\"{}\" />", image.url(), width, height); 30 32 31 33 // A more convenient way to render an image is to use the `render()` method, which generates an img tag for you and enforces accessibility by requiring an alt text. 32 - format!("{}", image.render("The logo of my project, a stylized crown")) 34 + Ok(format!("{}", image.render("The logo of my project, a stylized crown"))) 33 35 } 34 36 } 35 37 ``` 36 38 37 - Paths to image are resolved relative to the root of your project, not from the page's location. 39 + Paths to image are resolved relative to the root of your project, not from the page's location, as such `./image.png` and `image.png` both refer to the same file in the project root. 38 40 39 41 ### In Markdown 40 42 ··· 67 69 }, 68 70 )?; 69 71 70 - format!("<img src=\"{}\" alt=\"Processed Image\" />", image.url) 72 + Ok(format!("<img src=\"{}\" alt=\"Processed Image\" />", image.url)) 71 73 } 72 74 } 73 75 ``` ··· 90 92 91 93 impl Route for ImagePage { 92 94 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 93 - let image = ctx.assets.add_image("path/to/image.jpg"); 95 + let image = ctx.assets.add_image("path/to/image.jpg")?; 94 96 let placeholder = image.placeholder(); 95 97 96 - format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri()) 98 + Ok(format!("<img src=\"{}\" alt=\"Image with placeholder\" style=\"background-image: url('{}'); background-size: cover;\" />", image.url(), placeholder.data_uri())) 97 99 } 98 100 } 99 101 ```
+23 -8
website/content/docs/javascript.md
··· 6 6 7 7 Maudit supports adding JavaScript and TypeScript files to your site. 8 8 9 - To import a script, add it anywhere in your project's directory, and use the [`ctx.assets.add_script()`](https://docs.rs/maudit/latest/maudit/assets/struct.RouteAssets.html#method.add_script) method to add it to a page's assets. Paths are relative to the project's current working directory, not the file where the method is called. 9 + To import a script, add it anywhere in your project's directory, and use the [`ctx.assets.add_script()`](https://docs.rs/maudit/latest/maudit/assets/struct.RouteAssets.html#method.add_script) method to add it to a page's assets. 10 + 11 + This function will return an error if the image file does not exist, or cannot be read for any reason. If you'd rather not deal with errors, you can use the `add_script_unchecked()` method, which will instead panic on failure. 10 12 11 13 ```rs 12 14 use maudit::route::prelude::*; ··· 17 19 18 20 impl Route for Index { 19 21 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 20 - let script = ctx.assets.add_script("script.js"); 22 + let script = ctx.assets.add_script("script.js")?; 21 23 22 24 // Access the URL of the script using the `url()` method. 23 25 // This is useful when you want to manually add the script to your template. ··· 27 29 ); 28 30 29 31 // In supported templating languages, the return value of `ctx.assets.add_script()` can be used directly in the template. 30 - html! { 32 + Ok(html! { 31 33 (script) // Generates <script src="SCRIPT_URL" type="module"></script> 32 - } 34 + }) 33 35 } 34 36 } 35 37 ``` ··· 41 43 layout(&ctx, "Look ma, no explicit script tag!") 42 44 } 43 45 44 - fn layout(ctx: &PageContext, content: &str) -> Markup { 45 - ctx.assets.include_script("script.js"); 46 + fn layout(ctx: &PageContext, content: &str) -> impl Into<RenderResult> { 47 + ctx.assets.include_script("script.js")?; 46 48 47 - html! { 49 + Ok(html! { 48 50 head { 49 51 title { "My page" } 50 52 // No need to manually add the script here. ··· 52 54 body { 53 55 (PreEscaped(content)) 54 56 } 55 - } 57 + }) 56 58 } 57 59 ``` 58 60 59 61 When using `include_script()`, the script will be included inside the `head` tag with the `type="module"` attribute. [Note that this attribute implicitely means that your script will be deferred](https://v8.dev/features/modules#defer) after the page has loaded. Note that, at this time, pages without a `head` tag won't have the script included. 62 + 63 + In both cases, paths are relative to the project's current working directory, not the file where the method is called. It is possible to resolve relatively to the current file using Rust's [`file!()`](https://doc.rust-lang.org/std/macro.file.html) macro, if needed: 64 + 65 + ```rs 66 + let script = ctx.assets.add_script( 67 + std::path::Path::new(file!()) 68 + .parent() 69 + .unwrap() 70 + .join("script.js") 71 + .to_str() 72 + .unwrap(), 73 + )?; 74 + ``` 60 75 61 76 ## Transformation & Bundling 62 77
+7 -7
website/content/docs/styling.md
··· 17 17 18 18 impl Route for Blog { 19 19 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 20 - let style = ctx.assets.add_style("style.css"); 20 + let style = ctx.assets.add_style("style.css")?; 21 21 22 22 // Access the URL of the stylesheet using the `url()` method. 23 23 // This is useful when you want to manually add the stylesheet to your template. ··· 27 27 ); 28 28 29 29 // In supported templating languages, the return value of `ctx.assets.add_style()` can be used directly in the template. 30 - html! { 30 + Ok(html! { 31 31 (style) // Generates <link rel="stylesheet" href="STYLE_URL" /> 32 - } 32 + }) 33 33 } 34 34 } 35 35 ``` ··· 41 41 layout(&ctx, "Look ma, no link tag!") 42 42 } 43 43 44 - fn layout(ctx: &PageContext, content: &str) -> Markup { 45 - ctx.assets.include_style("style.css"); 44 + fn layout(ctx: &PageContext, content: &str) -> impl Into<RenderResult> { 45 + ctx.assets.include_style("style.css")?; 46 46 47 - html! { 47 + Ok(html! { 48 48 head { 49 49 title { "My page" } 50 50 // No need to manually add the stylesheet here. ··· 52 52 body { 53 53 (PreEscaped(content)) 54 54 } 55 - } 55 + }) 56 56 } 57 57 ``` 58 58
+6 -6
website/src/layout.rs
··· 75 75 headings: &[MarkdownHeading], 76 76 seo: Option<SeoMeta>, 77 77 ) -> impl Into<RenderResult> { 78 - ctx.assets.include_script("assets/docs-sidebar.ts"); 78 + ctx.assets.include_script("assets/docs-sidebar.ts")?; 79 79 80 80 layout( 81 81 html! { ··· 136 136 licenses: bool, 137 137 ctx: &mut PageContext, 138 138 seo: Option<SeoMeta>, 139 - ) -> impl Into<RenderResult> { 139 + ) -> Result<Markup, Box<dyn std::error::Error>> { 140 140 ctx.assets 141 - .include_style_with_options("assets/prin.css", StyleOptions { tailwind: true }); 141 + .include_style_with_options("assets/prin.css", StyleOptions { tailwind: true })?; 142 142 143 143 let seo_data = seo.unwrap_or_default(); 144 144 145 - html! { 145 + Ok(html! { 146 146 (DOCTYPE) 147 147 html lang="en" { 148 148 head { ··· 154 154 } 155 155 body { 156 156 div.relative.bg-our-white { 157 - (header(ctx, bottom_border)) 157 + (header(ctx, bottom_border)?) 158 158 (main) 159 159 footer.bg-our-black.text-white { 160 160 div.container.mx-auto.px-8.py-8.flex.justify-between.items-center.flex-col-reverse."sm:flex-row".gap-y-12 { ··· 188 188 } 189 189 } 190 190 } 191 - } 191 + }) 192 192 }
+7 -4
website/src/layout/header.rs
··· 3 3 use maud::html; 4 4 use maudit::route::PageContext; 5 5 6 - pub fn header(ctx: &mut PageContext, bottom_border: bool) -> Markup { 7 - ctx.assets.include_script("assets/mobile-menu.ts"); 6 + pub fn header( 7 + ctx: &mut PageContext, 8 + bottom_border: bool, 9 + ) -> Result<Markup, maudit::errors::AssetError> { 10 + ctx.assets.include_script("assets/mobile-menu.ts")?; 8 11 9 12 let border = if bottom_border { "border-b" } else { "" }; 10 13 let nav_links = vec![ ··· 26 29 ), 27 30 ]; 28 31 29 - html! { 32 + Ok(html! { 30 33 header.px-4.md:px-8.py-4.text-our-black.bg-our-white."border-borders".(border) { 31 34 div.container.flex.items-center.mx-auto.justify-between { 32 35 div.flex.items-center.gap-x-8 { ··· 79 82 } 80 83 } 81 84 } 82 - } 85 + }) 83 86 }