Rust library to generate static websites
5
fork

Configure Feed

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

feat: make it easier to use Maudit as a library (#37)

* feat: make it easier to build routes individually

* feat: library example

* fix: do content sources serially

* refactor: always use routes

authored by

Erika and committed by
GitHub
779bef7b 4496b9bc

+862 -320
+9
Cargo.lock
··· 2888 2888 ] 2889 2889 2890 2890 [[package]] 2891 + name = "maudit-example-library" 2892 + version = "0.1.0" 2893 + dependencies = [ 2894 + "maud", 2895 + "maudit", 2896 + "serde", 2897 + ] 2898 + 2899 + [[package]] 2891 2900 name = "maudit-example-markdown-components" 2892 2901 version = "0.1.0" 2893 2902 dependencies = [
+1 -1
benchmarks/md-benchmark/src/page.rs
··· 9 9 } 10 10 11 11 impl Page<Params> for Article { 12 - fn routes(&self, context: &mut DynamicRouteContext) -> Vec<Route<Params>> { 12 + fn routes(&self, context: &DynamicRouteContext) -> Routes<Params> { 13 13 context 14 14 .content 15 15 .get_source::<UntypedMarkdownContent>("articles")
+1 -1
crates/maudit-macros/src/lib.rs
··· 36 36 self.render(ctx).into() 37 37 } 38 38 39 - fn routes_internal(&self, ctx: &mut maudit::page::DynamicRouteContext) -> Vec<(maudit::page::RouteParams, Box<dyn std::any::Any + Send + Sync>, Box<dyn std::any::Any + Send + Sync>)> { 39 + fn routes_internal(&self, ctx: &maudit::page::DynamicRouteContext) -> Vec<(maudit::page::RouteParams, Box<dyn std::any::Any + Send + Sync>, Box<dyn std::any::Any + Send + Sync>)> { 40 40 self.routes(ctx) 41 41 .into_iter() 42 42 .map(|route| {
+12 -14
crates/maudit/CHANGELOG.md
··· 6 6 7 7 - [9cd5fdd](https://github.com/bruits/maudit/commit/9cd5fdd8abe3044bd09d48b96217e3a0d2878b13) Updates default quality for webp to 80 to match sharp — Thanks @Princesseuh! 8 8 9 - 10 9 ## 0.5.0 11 10 12 11 ### Minor changes ··· 14 13 - [d5a7fad](https://github.com/bruits/maudit/commit/d5a7fad563e9642be46b24d8db500e753c1175f5) The data URI and average RGBA for thumbnails is now calculated lazily, as such the `average_rgba` and `data_uri` fields have been replaced by methods. — Thanks @Princesseuh! 15 14 - [0403ac9](https://github.com/bruits/maudit/commit/0403ac9996f9d4e79945758fe06e7510729e383e) Add `is_dev()` function to allow one to toggle off things whenever running in dev — Thanks @Princesseuh! 16 15 - [39db004](https://github.com/bruits/maudit/commit/39db004b63ab7aa582a92593082e1261bae55b92) Added support for shortcodes in Markdown. Shortcodes allows you to substitute custom content in your Markdown files. This feature is useful for embedding dynamic content or reusable components within your Markdown documents. 17 - 16 + 18 17 For instance, you might define a shortcode for embedding YouTube videos using only the video ID, or for inserting custom alerts or notes. 19 - 18 + 20 19 ```markdown 21 20 {{ youtube id="FbJ63spk48s" }} 22 21 ``` 23 - 22 + 24 23 Would render to: 25 - 24 + 26 25 ```html 27 26 <iframe 28 27 width="560" ··· 35 34 allowfullscreen 36 35 ></iframe> 37 36 ``` 38 - 37 + 39 38 To define and register shortcodes, pass a MarkdownShortcodes instance to the MarkdownOptions when rendering Markdown content. 40 - 39 + 41 40 ```rust 42 41 let mut shortcodes = MarkdownShortcodes::new(); 43 - 42 + 44 43 shortcodes.register("youtube", |args, _ctx| { 45 44 let id: String = args.get_required("id"); 46 45 format!( ··· 48 47 id 49 48 ) 50 49 }); 51 - 50 + 52 51 MarkdownOptions { 53 52 shortcodes, 54 53 ..Default::default() 55 54 } 56 - 55 + 57 56 // Then pass options to, i.e. glob_markdown in a content source 58 57 ``` 59 - 58 + 60 59 Note that shortcodes are expanded before Markdown is rendered, so you can use shortcodes anywhere in your Markdown content, for instance in your frontmatter. Additionally, shortcodes may expand to Markdown content, which will then be rendered as part of the overall Markdown rendering process. — Thanks @Princesseuh! 61 60 62 61 ### Patch changes 63 62 64 63 - [d5a7fad](https://github.com/bruits/maudit/commit/d5a7fad563e9642be46b24d8db500e753c1175f5) Added caching mechanism to placeholder and image transformation — Thanks @Princesseuh! 65 - 66 64 67 65 ## 0.4.0 68 66 ··· 96 94 ``` 97 95 98 96 See the [Assets documentation](https://maudit.org/docs/assets/) for more details. — Thanks @Princesseuh! 97 + 99 98 - [52eda9e](https://github.com/bruits/maudit/commit/52eda9ea4eac8efd3efd945d00f39a1b99f284ab) Adds support for dynamic routes with properties. In addition to its parameters, a dynamic route can now provide additional properties that can be used during rendering. 100 99 101 100 ```rs ··· 126 125 ).into() 127 126 } 128 127 129 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Routes<Params, Props> { 128 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<Params, Props> { 130 129 vec![Route::from_params_and_props( 131 130 Params { 132 131 slug: "hello-world".to_string(), ··· 145 144 ### Patch changes 146 145 147 146 - Updated dependencies: maudit-macros@0.4.0 148 -
+39 -16
crates/maudit/src/assets.rs
··· 3 3 use log::debug; 4 4 use rustc_hash::FxHashSet; 5 5 use std::hash::Hash; 6 - use std::sync::OnceLock; 7 6 use std::time::Instant; 8 7 use std::{fs, path::PathBuf}; 9 8 use xxhash_rust::xxh3::xxh3_64; ··· 18 17 19 18 #[derive(Default)] 20 19 pub struct PageAssets { 21 - pub(crate) images: FxHashSet<Image>, 22 - pub(crate) scripts: FxHashSet<Script>, 23 - pub(crate) styles: FxHashSet<Style>, 24 - 25 - pub(crate) included_styles: Vec<Style>, 26 - pub(crate) included_scripts: Vec<Script>, 20 + pub images: FxHashSet<Image>, 21 + pub scripts: FxHashSet<Script>, 22 + pub styles: FxHashSet<Style>, 27 23 28 24 pub(crate) assets_dir: PathBuf, 29 25 } 30 26 31 27 impl PageAssets { 28 + pub fn new(assets_dir: PathBuf) -> Self { 29 + Self { 30 + assets_dir, 31 + ..Default::default() 32 + } 33 + } 34 + 35 + pub fn assets(&self) -> impl Iterator<Item = &dyn Asset> { 36 + self.images 37 + .iter() 38 + .map(|asset| asset as &dyn Asset) 39 + .chain(self.scripts.iter().map(|asset| asset as &dyn Asset)) 40 + .chain(self.styles.iter().map(|asset| asset as &dyn Asset)) 41 + } 42 + 43 + /// Get all styles that are marked as included 44 + pub fn included_styles(&self) -> impl Iterator<Item = &Style> { 45 + self.styles.iter().filter(|s| s.included) 46 + } 47 + 48 + /// Get all scripts that are marked as included 49 + pub fn included_scripts(&self) -> impl Iterator<Item = &Script> { 50 + self.scripts.iter().filter(|s| s.included) 51 + } 52 + 32 53 /// 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. 33 54 /// 34 55 /// The image will not automatically be included in the page, but can be included through the `.url()` method on the returned `Image` object. ··· 64 85 } else { 65 86 Some(options) 66 87 }, 67 - __cache_placeholder: OnceLock::new(), 68 88 }; 69 89 70 90 self.images.insert(image.clone()); ··· 94 114 path: path.clone(), 95 115 assets_dir: self.assets_dir.clone(), 96 116 hash: calculate_hash(&path, None), 117 + included: false, 97 118 }; 98 119 99 120 self.scripts.insert(script.clone()); ··· 115 136 path: path.clone(), 116 137 assets_dir: self.assets_dir.clone(), 117 138 hash: calculate_hash(&path, None), 139 + included: true, 118 140 }; 119 141 120 - self.scripts.insert(script.clone()); 121 - self.included_scripts.push(script); 142 + self.scripts.insert(script); 122 143 } 123 144 124 145 /// 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. ··· 149 170 assets_dir: self.assets_dir.clone(), 150 171 hash: calculate_hash(&path, Some(HashConfig::Style(&options))), 151 172 tailwind: options.tailwind, 173 + included: false, 152 174 }; 153 175 154 176 self.styles.insert(style.clone()); ··· 184 206 assets_dir: self.assets_dir.clone(), 185 207 hash, 186 208 tailwind: options.tailwind, 209 + included: true, 187 210 }; 188 211 189 - self.styles.insert(style.clone()); 190 - self.included_styles.push(style); 212 + self.styles.insert(style); 191 213 } 192 214 } 193 215 ··· 333 355 page_assets.include_style(temp_dir.join("style.css")); 334 356 335 357 assert!(page_assets.styles.len() == 1); 336 - assert!(page_assets.included_styles.len() == 1); 358 + assert!(page_assets.included_styles().count() == 1); 337 359 } 338 360 339 361 #[test] ··· 359 381 page_assets.include_script(temp_dir.join("script.js")); 360 382 361 383 assert!(page_assets.scripts.len() == 1); 362 - assert!(page_assets.included_scripts.len() == 1); 384 + assert!(page_assets.included_scripts().count() == 1); 363 385 } 364 386 365 387 #[test] ··· 459 481 let image_webp = page_assets.add_image_with_options( 460 482 &image_path, 461 483 ImageOptions { 462 - format: Some(ImageFormat::Webp), 484 + format: Some(ImageFormat::WebP), 463 485 ..Default::default() 464 486 }, 465 487 ); ··· 476 498 ImageOptions { 477 499 width: Some(100), 478 500 height: Some(100), 479 - format: Some(ImageFormat::Webp), 501 + format: Some(ImageFormat::WebP), 480 502 }, 481 503 ); 482 504 ··· 617 639 Some(HashConfig::Style(&StyleOptions::default())), 618 640 ), 619 641 tailwind: false, 642 + included: false, 620 643 }; 621 644 622 645 assert_ne!(
+9 -32
crates/maudit/src/assets/image.rs
··· 10 10 use crate::assets::{Asset, InternalAsset}; 11 11 use crate::is_dev; 12 12 13 - #[derive(Clone, PartialEq, Eq, Hash)] 13 + #[derive(Clone, Debug, PartialEq, Eq, Hash)] 14 14 pub enum ImageFormat { 15 15 Png, 16 16 Jpeg, 17 - Webp, 17 + WebP, 18 18 Avif, 19 19 Gif, 20 20 } ··· 24 24 match self { 25 25 ImageFormat::Png => "png", 26 26 ImageFormat::Jpeg => "jpg", 27 - ImageFormat::Webp => "webp", 27 + ImageFormat::WebP => "webp", 28 28 ImageFormat::Avif => "avif", 29 29 ImageFormat::Gif => "gif", 30 30 } ··· 34 34 match self { 35 35 ImageFormat::Png => 1, 36 36 ImageFormat::Jpeg => 2, 37 - ImageFormat::Webp => 3, 37 + ImageFormat::WebP => 3, 38 38 ImageFormat::Gif => 4, 39 39 ImageFormat::Avif => 5, 40 40 } ··· 46 46 match val { 47 47 ImageFormat::Png => image::ImageFormat::Png, 48 48 ImageFormat::Jpeg => image::ImageFormat::Jpeg, 49 - ImageFormat::Webp => image::ImageFormat::WebP, 49 + ImageFormat::WebP => image::ImageFormat::WebP, 50 50 ImageFormat::Avif => image::ImageFormat::Avif, 51 51 ImageFormat::Gif => image::ImageFormat::Gif, 52 52 } 53 53 } 54 54 } 55 55 56 - #[derive(Default, Clone, PartialEq, Eq, Hash)] 56 + #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] 57 57 pub struct ImageOptions { 58 58 pub width: Option<u32>, 59 59 pub height: Option<u32>, 60 60 pub format: Option<ImageFormat>, 61 61 } 62 62 63 - #[derive(Clone)] 64 - #[non_exhaustive] 63 + #[derive(Clone, Debug, Hash, PartialEq, Eq)] 65 64 pub struct Image { 66 65 pub path: PathBuf, 67 66 pub width: u32, ··· 69 68 pub(crate) assets_dir: PathBuf, 70 69 pub(crate) hash: String, 71 70 pub(crate) options: Option<ImageOptions>, 72 - pub(crate) __cache_placeholder: OnceLock<ImagePlaceholder>, 73 71 } 74 72 75 - impl Hash for Image { 76 - fn hash<H: std::hash::Hasher>(&self, state: &mut H) { 77 - self.path.hash(state); 78 - self.assets_dir.hash(state); 79 - self.hash.hash(state); 80 - self.options.hash(state); 81 - } 82 - } 83 - 84 - impl PartialEq for Image { 85 - fn eq(&self, other: &Self) -> bool { 86 - self.path == other.path 87 - && self.assets_dir == other.assets_dir 88 - && self.hash == other.hash 89 - && self.options == other.options 90 - } 91 - } 92 - 93 - impl Eq for Image {} 94 - 95 73 impl Image { 96 74 /// Get a placeholder for the image, which can be used for low-quality image placeholders (LQIP) or similar techniques. 97 75 /// 98 76 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 99 - pub fn placeholder(&self) -> &ImagePlaceholder { 100 - self.__cache_placeholder 101 - .get_or_init(|| get_placeholder(&self.path)) 77 + pub fn placeholder(&self) -> ImagePlaceholder { 78 + get_placeholder(&self.path) 102 79 } 103 80 } 104 81
+1
crates/maudit/src/assets/script.rs
··· 8 8 pub path: PathBuf, 9 9 pub(crate) assets_dir: PathBuf, 10 10 pub(crate) hash: String, 11 + pub(crate) included: bool, 11 12 } 12 13 13 14 impl InternalAsset for Script {
+1
crates/maudit/src/assets/style.rs
··· 14 14 pub(crate) assets_dir: PathBuf, 15 15 pub(crate) hash: String, 16 16 pub(crate) tailwind: bool, 17 + pub(crate) included: bool, 17 18 } 18 19 19 20 impl InternalAsset for Style {
+64 -114
crates/maudit/src/build.rs
··· 1 1 use core::panic; 2 2 use std::{ 3 3 env, 4 - fs::{self, File, remove_dir_all}, 5 - io::{self, Write}, 4 + fs::{self}, 5 + io::{self}, 6 6 path::{Path, PathBuf}, 7 7 process::Command, 8 8 str::FromStr, ··· 13 13 use crate::{ 14 14 BuildOptions, BuildOutput, 15 15 assets::{ 16 - self, 16 + self, PageAssets, 17 17 image_cache::{IMAGE_CACHE_DIR, ImageCache}, 18 18 }, 19 19 build::images::process_image, 20 - content::{Content, ContentSources}, 20 + content::{ContentSources, PageContent}, 21 21 errors::BuildError, 22 22 is_dev, 23 23 logging::print_title, 24 24 page::{DynamicRouteContext, FullPage, RenderResult, RouteContext, RouteParams, RouteType}, 25 - route::{ 26 - ParameterDef, extract_params_from_raw_route, get_route_file_path, 27 - get_route_type_from_route_params, get_route_url, 28 - }, 29 25 }; 30 26 use colored::{ColoredString, Colorize}; 31 27 use log::{debug, info, trace}; ··· 161 157 // Create a directory for the output 162 158 trace!(target: "build", "Setting up required directories..."); 163 159 let dist_dir = PathBuf::from_str(&options.output_dir)?; 164 - let assets_dir = PathBuf::from_str(&options.output_dir)?.join(&options.assets_dir); 165 - let tmp_dir = dist_dir.join("_tmp"); 160 + let final_assets_dir = PathBuf::from_str(&options.output_dir)?.join(&options.assets_dir); 166 161 let static_dir = PathBuf::from_str(&options.static_dir)?; 167 162 168 163 let old_dist_tmp_dir = if options.clean_output_dir { ··· 183 178 }); 184 179 185 180 fs::create_dir_all(&dist_dir)?; 186 - fs::create_dir_all(&assets_dir)?; 181 + fs::create_dir_all(&final_assets_dir)?; 187 182 188 183 info!(target: "build", "Output directory: {}", dist_dir.to_string_lossy()); 189 184 190 185 let content_sources_start = Instant::now(); 191 186 print_title("initializing content sources"); 192 - content_sources.0.iter_mut().for_each(|source| { 187 + content_sources.sources_mut().iter_mut().for_each(|source| { 193 188 let source_start = Instant::now(); 194 189 source.init(); 195 190 ··· 224 219 ..Default::default() 225 220 }; 226 221 227 - #[allow(clippy::mutable_key_type)] // Image's Hash does not depend on mutable fields 228 222 let mut build_pages_images: FxHashSet<assets::Image> = FxHashSet::default(); 229 223 let mut build_pages_scripts: FxHashSet<assets::Script> = FxHashSet::default(); 230 224 let mut build_pages_styles: FxHashSet<assets::Style> = FxHashSet::default(); ··· 235 229 // faster in all cases, making it sometimes even slower due to the overhead. It'd be great to investigate and benchmark 236 230 // this. 237 231 for route in routes { 238 - let params_def = extract_params_from_raw_route(&route.route_raw()); 239 - let route_type = get_route_type_from_route_params(&params_def); 240 - match route_type { 232 + match route.route_type() { 241 233 RouteType::Static => { 242 234 let route_start = Instant::now(); 243 - let mut page_assets = assets::PageAssets { 244 - assets_dir: options.assets_dir.clone().into(), 245 - ..Default::default() 246 - }; 247 235 248 - let params = RouteParams(FxHashMap::default()); 236 + let content = PageContent::new(content_sources); 237 + let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 249 238 250 - let mut content = Content::new(&content_sources.0); 251 - let mut ctx = RouteContext { 252 - raw_params: &params, 253 - content: &mut content, 254 - assets: &mut page_assets, 255 - current_url: get_route_url(&route.route_raw(), &params_def, &params), 239 + let params = RouteParams::default(); 240 + let url = route.url(&params); 256 241 257 - // Static routes have no params or props 258 - params: &(), 259 - props: &(), 260 - }; 242 + let result = route.build(&mut RouteContext::from_static_route( 243 + &content, 244 + &mut page_assets, 245 + url.clone(), 246 + ))?; 261 247 262 - let (file_path, mut file) = 263 - create_route_file(*route, &params_def, &params, &dist_dir)?; 264 - let result = route.render_internal(&mut ctx); 248 + let file_path = &dist_dir.join(route.file_path(&params)); 265 249 266 - finish_route( 267 - result, 268 - &mut file, 269 - &page_assets.included_styles, 270 - &page_assets.included_scripts, 271 - route.route_raw(), 272 - )?; 250 + write_route_file(&result, file_path)?; 273 251 274 - info!(target: "pages", "{} -> {} {}", get_route_url(&route.route_raw(), &params_def, &params), file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 252 + info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 275 253 276 254 build_pages_images.extend(page_assets.images); 277 255 build_pages_scripts.extend(page_assets.scripts); ··· 286 264 page_count += 1; 287 265 } 288 266 RouteType::Dynamic => { 289 - let mut dynamic_content = Content::new(&content_sources.0); 290 - let mut dynamic_route_context = DynamicRouteContext { 291 - content: &mut dynamic_content, 292 - }; 293 - 294 - let routes = route.routes_internal(&mut dynamic_route_context); 267 + let routes = route.routes_internal(&DynamicRouteContext { 268 + content: &PageContent::new(content_sources), 269 + }); 295 270 296 271 if routes.is_empty() { 297 272 info!(target: "build", "{} is a dynamic route, but its implementation of Page::routes returned an empty Vec. No pages will be generated for this route.", route.route_raw().to_string().bold()); ··· 300 275 info!(target: "build", "{}", route.route_raw().to_string().bold()); 301 276 } 302 277 303 - for (params, typed_params, props) in routes { 304 - let mut pages_assets = assets::PageAssets { 305 - assets_dir: options.assets_dir.clone().into(), 306 - ..Default::default() 307 - }; 278 + let content = PageContent::new(content_sources); 279 + for dynamic_route in routes { 308 280 let route_start = Instant::now(); 309 - let mut content = Content::new(&content_sources.0); 310 - let mut ctx = RouteContext { 311 - raw_params: &params, 312 - params: typed_params.as_ref(), 313 - props: props.as_ref(), 314 - content: &mut content, 315 - assets: &mut pages_assets, 316 - current_url: get_route_url(&route.route_raw(), &params_def, &params), 317 - }; 318 281 319 - let (file_path, mut file) = 320 - create_route_file(*route, &params_def, &params, &dist_dir)?; 282 + let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 283 + 284 + let url = route.url(&dynamic_route.0); 285 + 286 + let content = route.build(&mut RouteContext::from_dynamic_route( 287 + &dynamic_route, 288 + &content, 289 + &mut page_assets, 290 + url, 291 + ))?; 292 + 293 + let file_path = &dist_dir.join(route.file_path(&dynamic_route.0)); 321 294 322 - let result = route.render_internal(&mut ctx); 295 + write_route_file(&content, file_path)?; 296 + 297 + info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 298 + 299 + build_pages_images.extend(page_assets.images); 300 + build_pages_scripts.extend(page_assets.scripts); 301 + build_pages_styles.extend(page_assets.styles); 323 302 324 303 build_metadata.add_page( 325 304 route.route_raw().to_string(), 326 305 file_path.to_string_lossy().to_string(), 327 - Some(params.0), 306 + Some(dynamic_route.0.0), 328 307 ); 329 308 330 - finish_route( 331 - result, 332 - &mut file, 333 - &pages_assets.included_styles, 334 - &pages_assets.included_scripts, 335 - route.route_raw(), 336 - )?; 337 - 338 - info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 339 - 340 - build_pages_images.extend(pages_assets.images); 341 - build_pages_scripts.extend(pages_assets.scripts); 342 - build_pages_styles.extend(pages_assets.styles); 343 - 344 309 page_count += 1; 345 310 } 346 311 } ··· 398 363 BundlerOptions { 399 364 input: Some(bundler_inputs), 400 365 minify: Some(rolldown::RawMinifyOptions::Bool(!is_dev())), 401 - dir: Some(assets_dir.to_string_lossy().to_string()), 366 + dir: Some(final_assets_dir.to_string_lossy().to_string()), 402 367 module_types: Some(module_types_hashmap), 403 368 404 369 ..Default::default() ··· 434 399 let start_time = Instant::now(); 435 400 build_pages_images.par_iter().for_each(|image| { 436 401 let start_process = Instant::now(); 437 - let dest_path = assets_dir.join(image.final_file_name()); 402 + let dest_path = final_assets_dir.join(image.final_file_name()); 438 403 439 404 if let Some(image_options) = &image.options { 440 405 let final_filename = image.final_file_name(); ··· 489 454 info!(target: "build", "{}", format!("Assets copied in {}", format_elapsed_time(assets_start.elapsed(), &FormatElapsedTimeOptions::default())).bold()); 490 455 } 491 456 492 - // Remove temporary files 493 - let _ = remove_dir_all(&tmp_dir); 494 - 495 457 info!(target: "SKIP_FORMAT", "{}", ""); 496 458 info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), &section_format_options)).bold()); 497 459 ··· 531 493 Ok(()) 532 494 } 533 495 534 - fn create_route_file( 535 - route: &dyn FullPage, 536 - params_def: &Vec<ParameterDef>, 537 - params: &RouteParams, 538 - dist_dir: &Path, 539 - ) -> Result<(PathBuf, File), Box<dyn std::error::Error>> { 540 - let file_path = dist_dir.join(get_route_file_path( 541 - &route.route_raw(), 542 - params_def, 543 - params, 544 - route.is_endpoint(), 545 - )); 546 - 496 + fn write_route_file(content: &[u8], file_path: &PathBuf) -> Result<(), io::Error> { 547 497 // Create the parent directories if it doesn't exist 548 498 if let Some(parent_dir) = file_path.parent() { 549 499 fs::create_dir_all(parent_dir)? 550 500 } 551 501 552 - // Create file 553 - let file = File::create(file_path.clone())?; 502 + fs::write(file_path, content)?; 554 503 555 - Ok((file_path, file)) 504 + Ok(()) 556 505 } 557 506 558 - fn finish_route( 507 + pub fn finish_route( 559 508 render_result: RenderResult, 560 - file: &mut File, 561 - included_styles: &[assets::Style], 562 - included_scripts: &[assets::Script], 509 + page_assets: &assets::PageAssets, 563 510 route: String, 564 - ) -> Result<(), Box<dyn std::error::Error>> { 511 + ) -> Result<Vec<u8>, Box<dyn std::error::Error>> { 565 512 match render_result { 566 513 RenderResult::Text(html) => { 514 + let included_styles: Vec<_> = page_assets.included_styles().collect(); 515 + let included_scripts: Vec<_> = page_assets.included_scripts().collect(); 516 + 567 517 if included_scripts.is_empty() && included_styles.is_empty() { 568 - file.write_all(html.as_bytes())?; 569 - return Ok(()); 518 + return Ok(html.into_bytes()); 570 519 } 571 520 572 521 let element_content_handlers = vec![ 573 522 // Add included scripts and styles to the head 574 523 element!("head", |el| { 575 - for style in included_styles { 524 + for style in &included_styles { 576 525 el.append( 577 526 &format!( 578 527 "<link rel=\"stylesheet\" href=\"{}\">", ··· 585 534 ); 586 535 } 587 536 588 - for script in included_scripts { 537 + for script in &included_scripts { 589 538 el.append( 590 539 &format!( 591 540 "<script src=\"{}\" type=\"module\"></script>", ··· 610 559 }, 611 560 )?; 612 561 613 - file.write_all(output.as_bytes())?; 562 + Ok(output.into_bytes()) 614 563 } 615 564 RenderResult::Raw(content) => { 565 + let included_styles: Vec<_> = page_assets.included_styles().collect(); 566 + let included_scripts: Vec<_> = page_assets.included_scripts().collect(); 567 + 616 568 if !included_scripts.is_empty() || !included_styles.is_empty() { 617 569 Err(BuildError::InvalidRenderResult { route })?; 618 570 } 619 571 620 - file.write_all(&content)?; 572 + Ok(content) 621 573 } 622 574 } 623 - 624 - Ok(()) 625 575 }
+2 -2
crates/maudit/src/build/images.rs
··· 8 8 pub fn process_image(image: &Image, dest_path: &Path, image_options: &ImageOptions) { 9 9 let mut img = ImageReader::open(image.path()).unwrap().decode().unwrap(); 10 10 11 - let new_format = image_options.format.clone().unwrap_or(ImageFormat::Webp); 11 + let new_format = image_options.format.clone().unwrap_or(ImageFormat::WebP); 12 12 let new_width = image_options.width.unwrap_or(img.width()); 13 13 let new_height = image_options.height.unwrap_or(img.height()); 14 14 ··· 19 19 // image doesn't support lossy WebP encoding, so we'll use webp directly for that to avoid huge files 20 20 // TODO: Add a feature so that people can choose not to depend on libwebp 21 21 // TODO: Add a way for people to choose lossless WebP encoding, despite the larger file sizes 22 - if new_format == ImageFormat::Webp { 22 + if new_format == ImageFormat::WebP { 23 23 let encoder: Encoder = Encoder::from_image(&img).unwrap(); 24 24 let webp: WebPMemory = encoder.encode(80f32); // TODO: Allow configuring quality 25 25 std::fs::write(dest_path, &*webp).unwrap();
+26 -7
crates/maudit/src/content.rs
··· 133 133 /// article.render(ctx).into() 134 134 /// } 135 135 /// 136 - /// fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { 136 + /// fn routes(&self, ctx: &DynamicRouteContext) -> Vec<ArticleParams> { 137 137 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 138 138 /// 139 139 /// articles.into_params(|entry| ArticleParams { ··· 142 142 /// } 143 143 /// } 144 144 /// ``` 145 - pub struct Content<'a> { 145 + pub struct PageContent<'a> { 146 146 sources: &'a [Box<dyn ContentSourceInternal>], 147 147 } 148 148 149 - impl Content<'_> { 150 - pub fn new(sources: &'_ [Box<dyn ContentSourceInternal>]) -> Content<'_> { 151 - Content { sources } 149 + impl PageContent<'_> { 150 + pub fn new(sources: &'_ ContentSources) -> PageContent<'_> { 151 + PageContent { 152 + sources: sources.sources(), 153 + } 152 154 } 153 155 154 156 pub fn get_untyped_source(&self, name: &str) -> &ContentSource<Untyped> { ··· 311 313 /// pub fn content_sources() -> ContentSources { 312 314 /// content_sources!["docs" => glob_markdown::<ArticleContent>("content/docs/*.md", None)] 313 315 /// } 314 - pub struct ContentSources(pub Vec<Box<dyn ContentSourceInternal>>); 316 + pub struct ContentSources(pub(crate) Vec<Box<dyn ContentSourceInternal>>); 315 317 316 318 impl From<Vec<Box<dyn ContentSourceInternal>>> for ContentSources { 317 319 fn from(content_sources: Vec<Box<dyn ContentSourceInternal>>) -> Self { ··· 322 324 impl ContentSources { 323 325 pub fn new(content_sources: Vec<Box<dyn ContentSourceInternal>>) -> Self { 324 326 Self(content_sources) 327 + } 328 + 329 + pub fn sources(&self) -> &Vec<Box<dyn ContentSourceInternal>> { 330 + &self.0 331 + } 332 + 333 + pub fn sources_mut(&mut self) -> &mut Vec<Box<dyn ContentSourceInternal>> { 334 + &mut self.0 335 + } 336 + 337 + pub fn init_all(&mut self) { 338 + for source in &mut self.0 { 339 + source.init(); 340 + } 325 341 } 326 342 } 327 343 ··· 364 380 self.entries.iter().map(cb).collect() 365 381 } 366 382 367 - pub fn into_routes<Params, Props>(&self, cb: impl Fn(&ContentEntry<T>) -> crate::page::Route<Params, Props>) -> Vec<crate::page::Route<Params, Props>> 383 + pub fn into_routes<Params, Props>( 384 + &self, 385 + cb: impl Fn(&ContentEntry<T>) -> crate::page::Route<Params, Props>, 386 + ) -> Vec<crate::page::Route<Params, Props>> 368 387 where 369 388 Params: Into<RouteParams>, 370 389 {
+2 -6
crates/maudit/src/content/markdown/shortcodes_tests.rs
··· 58 58 { 59 59 use crate::{ 60 60 assets::PageAssets, 61 - content::{Content, ContentSources}, 62 - page::RouteParams, 61 + content::{ContentSources, PageContent}, 63 62 }; 64 - use rustc_hash::FxHashMap; 65 63 use std::path::PathBuf; 66 64 67 - let params = RouteParams(FxHashMap::default()); 68 65 let content_sources = ContentSources::new(vec![]); 69 - let content = Content::new(&content_sources.0); 66 + let content = PageContent::new(&content_sources); 70 67 let mut page_assets = PageAssets { 71 68 assets_dir: PathBuf::from("assets"), 72 69 ..Default::default() 73 70 }; 74 71 75 72 let mut ctx = RouteContext { 76 - raw_params: &params, 77 73 content: &content, 78 74 assets: &mut page_assets, 79 75 current_url: "/test".to_string(),
+1 -1
crates/maudit/src/lib.rs
··· 168 168 #[macro_export] 169 169 macro_rules! content_sources { 170 170 ($($name:expr => $entries:expr),*) => { 171 - maudit::content::ContentSources(vec![$(Box::new(maudit::content::ContentSource::new($name, Box::new(move || $entries)))),*]) 171 + maudit::content::ContentSources::new(vec![$(Box::new(maudit::content::ContentSource::new($name, Box::new(move || $entries)))),*]) 172 172 }; 173 173 } 174 174 /// The version of Maudit being used.
+118 -22
crates/maudit/src/page.rs
··· 2 2 //! 3 3 //! Every page must implement the [`Page`] trait. Then, pages can be passed to [`coronate()`](crate::coronate), through the [`routes!`](crate::routes) macro, to be built. 4 4 use crate::assets::PageAssets; 5 - use crate::content::Content; 6 - use crate::route::{extract_params_from_raw_route, get_route_url, guess_if_route_is_endpoint}; 5 + use crate::build::finish_route; 6 + use crate::content::PageContent; 7 + use crate::route::{ 8 + extract_params_from_raw_route, get_route_type_from_route_params, guess_if_route_is_endpoint, 9 + }; 7 10 use rustc_hash::FxHashMap; 8 11 use std::any::Any; 9 12 ··· 139 142 entries: &[crate::content::ContentEntry<T>], 140 143 per_page: usize, 141 144 mut params_fn: impl FnMut(usize) -> Params, 142 - ) -> Vec<Route<Params, PaginationMeta>> 145 + ) -> Routes<Params, PaginationMeta> 143 146 where 144 147 Params: Into<RouteParams>, 145 148 { ··· 203 206 /// } 204 207 /// } 205 208 pub struct RouteContext<'a> { 206 - pub raw_params: &'a RouteParams, 207 209 pub params: &'a dyn Any, 208 210 pub props: &'a dyn Any, 209 - pub content: &'a Content<'a>, 211 + pub content: &'a PageContent<'a>, 210 212 pub assets: &'a mut PageAssets, 211 213 pub current_url: String, 212 214 } 213 215 214 - impl RouteContext<'_> { 216 + impl<'a> RouteContext<'a> { 217 + pub fn from_static_route( 218 + content: &'a PageContent, 219 + assets: &'a mut PageAssets, 220 + current_url: String, 221 + ) -> Self { 222 + Self { 223 + params: &(), 224 + props: &(), 225 + content, 226 + assets, 227 + current_url, 228 + } 229 + } 230 + 231 + pub fn from_dynamic_route( 232 + dynamic_route: &'a RouteResult, 233 + content: &'a PageContent, 234 + assets: &'a mut PageAssets, 235 + current_url: String, 236 + ) -> Self { 237 + Self { 238 + params: dynamic_route.1.as_ref(), 239 + props: dynamic_route.2.as_ref(), 240 + content, 241 + assets, 242 + current_url, 243 + } 244 + } 245 + 215 246 pub fn params<T: 'static + Clone>(&self) -> T { 216 247 self.params 217 248 .downcast_ref::<T>() ··· 268 299 /// article.render().into() 269 300 /// } 270 301 /// 271 - /// fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<ArticleParams> { 302 + /// fn routes(&self, ctx: &DynamicRouteContext) -> Vec<ArticleParams> { 272 303 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 273 304 /// 274 305 /// articles.into_params(|entry| ArticleParams { ··· 278 309 /// } 279 310 /// ``` 280 311 pub struct DynamicRouteContext<'a> { 281 - pub content: &'a mut Content<'a>, 312 + pub content: &'a PageContent<'a>, 282 313 } 283 314 284 315 /// Must be implemented for every page of your website. ··· 304 335 Props: 'static, 305 336 T: Into<RenderResult>, 306 337 { 307 - fn routes(&self, _ctx: &mut DynamicRouteContext) -> Vec<Route<Params, Props>> { 338 + fn routes(&self, _ctx: &DynamicRouteContext) -> Routes<Params, Props> { 308 339 Vec::new() 309 340 } 310 341 fn render(&self, ctx: &mut RouteContext) -> T; ··· 345 376 } 346 377 } 347 378 348 - #[doc(hidden)] 349 379 #[derive(PartialEq, Eq, Debug)] 350 380 /// Used internally by Maudit and should not be implemented by the user. 351 381 /// We expose it because [`maudit_macros::route`] implements it for the user behind the scenes. ··· 362 392 fn is_endpoint(&self) -> bool { 363 393 guess_if_route_is_endpoint(&self.route_raw()) 364 394 } 395 + fn route_type(&self) -> RouteType { 396 + let params_def = extract_params_from_raw_route(&self.route_raw()); 397 + 398 + get_route_type_from_route_params(&params_def) 399 + } 400 + 401 + fn url(&self, params: &RouteParams) -> String { 402 + let params_def = extract_params_from_raw_route(&self.route_raw()); 403 + 404 + // Replace every param_def with the value from the params hashmap for said key 405 + // So, ex: "/articles/[article]" (params: Hashmap {article: "truc"}) -> "/articles/truc" 406 + let mut route = self.route_raw(); 407 + 408 + for param_def in params_def { 409 + let value = params.0.get(&param_def.key); 410 + 411 + match value { 412 + Some(value) => { 413 + route.replace_range(param_def.index..param_def.index + param_def.length, value); 414 + } 415 + None => { 416 + panic!( 417 + "Route {:?} is missing parameter {:?}", 418 + self.route_raw(), 419 + param_def.key 420 + ); 421 + } 422 + } 423 + } 424 + 425 + route 426 + } 427 + 428 + fn file_path(&self, params: &RouteParams) -> String { 429 + let params_def = extract_params_from_raw_route(&self.route_raw()); 430 + let mut route = self.route_raw(); 431 + 432 + for param_def in params_def { 433 + let value = params.0.get(&param_def.key); 434 + 435 + match value { 436 + Some(value) => { 437 + route.replace_range(param_def.index..param_def.index + param_def.length, value); 438 + } 439 + None => { 440 + panic!( 441 + "Route {:?} is missing parameter {:?}", 442 + self.route_raw(), 443 + param_def.key 444 + ); 445 + } 446 + } 447 + } 448 + 449 + let cleaned_raw_route = route.trim_start_matches('/').to_string(); 450 + 451 + match self.is_endpoint() { 452 + true => cleaned_raw_route, 453 + false => match cleaned_raw_route.is_empty() { 454 + true => "index.html".to_string(), 455 + false => format!("{}/index.html", cleaned_raw_route), 456 + }, 457 + } 458 + } 365 459 } 366 460 367 - #[doc(hidden)] 368 461 /// Used internally by Maudit and should not be implemented by the user. 369 462 /// We expose it because [`maudit_macros::route`] implements it for the user behind the scenes. 370 463 pub trait FullPage: InternalPage + Sync + Send { 371 464 fn render_internal(&self, ctx: &mut RouteContext) -> RenderResult; 372 - fn routes_internal(&self, context: &mut DynamicRouteContext) -> RoutesInternalResult; 465 + fn routes_internal(&self, context: &DynamicRouteContext) -> RoutesResult; 466 + 467 + fn build(&self, ctx: &mut RouteContext) -> Result<Vec<u8>, Box<dyn std::error::Error>> { 468 + let result = self.render_internal(ctx); 469 + let bytes = finish_route(result, ctx.assets, self.route_raw())?; 470 + 471 + Ok(bytes) 472 + } 373 473 } 374 474 375 - type RoutesInternalResult = Vec<( 376 - RouteParams, 377 - Box<dyn Any + Send + Sync>, 378 - Box<dyn Any + Send + Sync>, 379 - )>; 475 + pub type RouteResult = (RouteParams, RouteProps, RouteTypedParams); 476 + pub type RoutesResult = Vec<RouteResult>; 477 + 478 + pub type RouteProps = Box<dyn Any + Send + Sync>; 479 + pub type RouteTypedParams = Box<dyn Any + Send + Sync>; 380 480 381 481 pub fn get_page_url<T: Into<RouteParams>>(route: &impl FullPage, params: T) -> String { 382 - let params_defs = extract_params_from_raw_route(&route.route_raw()); 383 - format!( 384 - "/{}", 385 - get_route_url(&route.route_raw(), &params_defs, &params.into()) 386 - ) 482 + format!("/{}", route.url(&params.into()).trim_start_matches('/')) 387 483 } 388 484 389 485 pub mod prelude {
+6 -92
crates/maudit/src/route.rs
··· 1 1 use std::path::Path; 2 2 3 - use crate::page::{RouteParams, RouteType}; 3 + use crate::page::RouteType; 4 4 5 5 #[derive(Debug, PartialEq)] 6 6 pub struct ParameterDef { 7 - key: String, 8 - index: usize, 9 - length: usize, 7 + pub(crate) key: String, 8 + pub(crate) index: usize, 9 + pub(crate) length: usize, 10 10 } 11 11 12 12 pub fn extract_params_from_raw_route(raw_route: &str) -> Vec<ParameterDef> { ··· 63 63 } 64 64 } 65 65 66 - /// "/articles/[article]" (params: Hashmap {article: "truc"}) -> "articles/truc/index.html" 67 - pub fn get_route_file_path( 68 - raw_route: &str, 69 - params_def: &Vec<ParameterDef>, 70 - params: &RouteParams, 71 - is_endpoint: bool, 72 - ) -> String { 73 - // Replace every param_def with the value from the params hashmap for said key 74 - // So, ex: "/articles/[article]" (params: Hashmap {article: "truc"}) -> "/articles/truc" 75 - let mut route = String::from(raw_route); 76 - 77 - for param_def in params_def { 78 - let value = params.0.get(&param_def.key); 79 - 80 - match value { 81 - Some(value) => { 82 - route.replace_range(param_def.index..param_def.index + param_def.length, value); 83 - } 84 - None => { 85 - panic!( 86 - "Route {:?} is missing parameter {:?}", 87 - raw_route, param_def.key 88 - ); 89 - } 90 - } 91 - } 92 - 93 - let cleaned_raw_route = route.trim_start_matches('/').to_string(); 94 - 95 - match is_endpoint { 96 - true => cleaned_raw_route, 97 - false => match cleaned_raw_route.is_empty() { 98 - true => "index.html".to_string(), 99 - false => format!("{}/index.html", cleaned_raw_route), 100 - }, 101 - } 102 - } 103 - 104 - pub fn get_route_url( 105 - raw_route: &str, 106 - params_def: &Vec<ParameterDef>, 107 - params: &RouteParams, 108 - ) -> String { 109 - // Replace every param_def with the value from the params hashmap for said key 110 - // So, ex: "/articles/[article]" (params: Hashmap {article: "truc"}) -> "/articles/truc" 111 - let mut route = String::from(raw_route); 112 - 113 - for param_def in params_def { 114 - let value = params.0.get(&param_def.key); 115 - 116 - match value { 117 - Some(value) => { 118 - route.replace_range(param_def.index..param_def.index + param_def.length, value); 119 - } 120 - None => { 121 - panic!( 122 - "Route {:?} is missing parameter {:?}", 123 - raw_route, param_def.key 124 - ); 125 - } 126 - } 127 - } 128 - 129 - route 130 - } 131 - 132 66 pub fn guess_if_route_is_endpoint(raw_route: &str) -> bool { 133 67 let real_path = Path::new(&raw_route); 134 68 ··· 138 72 #[cfg(test)] 139 73 mod tests { 140 74 use crate::{ 141 - page::{RouteParams, RouteType}, 142 - route::{ 143 - extract_params_from_raw_route, get_route_file_path, get_route_type_from_route_params, 144 - ParameterDef, 145 - }, 75 + page::RouteType, 76 + route::{ParameterDef, extract_params_from_raw_route, get_route_type_from_route_params}, 146 77 }; 147 78 148 79 #[test] ··· 259 190 assert_eq!( 260 191 get_route_type_from_route_params(&params_def), 261 192 RouteType::Dynamic 262 - ); 263 - } 264 - 265 - #[test] 266 - fn test_get_route_file_path() { 267 - let raw_route = "/articles/[article]"; 268 - let is_endpoint = false; 269 - let params_def = extract_params_from_raw_route(raw_route); 270 - let mut params = RouteParams::default(); 271 - 272 - params 273 - .0 274 - .insert("article".to_string(), "something".to_string()); 275 - 276 - assert_eq!( 277 - get_route_file_path(raw_route, &params_def, &params, is_endpoint), 278 - "articles/something/index.html" 279 193 ); 280 194 } 281 195 }
+1 -1
crates/oubli/src/archetypes/blog.rs
··· 43 43 pub entry: String, 44 44 } 45 45 46 - pub fn blog_entry_routes(ctx: &mut DynamicRouteContext, name: &str) -> Vec<Route<BlogEntryParams>> { 46 + pub fn blog_entry_routes(ctx: &DynamicRouteContext, name: &str) -> Routes<BlogEntryParams> { 47 47 let blog_entries = ctx.content.get_source::<BlogEntryContent>(name); 48 48 49 49 blog_entries.into_routes(|entry| {
+5 -3
crates/oubli/src/lib.rs
··· 83 83 blog_entry_render(ctx, $name, stringify!($ident)).into() 84 84 } 85 85 86 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<Route<BlogEntryParams>> { 86 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<BlogEntryParams> { 87 87 blog_entry_routes(ctx, stringify!($ident)) 88 88 } 89 89 } ··· 139 139 let mut content_sources_archetypes = Vec::new(); 140 140 141 141 content_sources 142 - .0 142 + .sources_mut() 143 143 .push(generate_archetype_store(&archetypes)); 144 144 145 145 for (_name, _stringified_ident, pages, content_source) in archetypes { ··· 147 147 combined_routes.extend(pages); 148 148 } 149 149 150 - content_sources.0.extend(content_sources_archetypes); 150 + content_sources 151 + .sources_mut() 152 + .extend(content_sources_archetypes); 151 153 152 154 // At the end of the day, we are just a Maudit wrapper. 153 155 coronate(&combined_routes, content_sources, options)
+1 -1
examples/blog/src/pages/article.rs
··· 11 11 } 12 12 13 13 impl Page<ArticleParams> for Article { 14 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<Route<ArticleParams>> { 14 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<ArticleParams> { 15 15 let articles = ctx.content.get_source::<ArticleContent>("articles"); 16 16 17 17 articles.into_routes(|entry| {
+1 -1
examples/image-processing/src/pages/index.rs
··· 13 13 ImageOptions { 14 14 width: Some(200), 15 15 height: Some(200), 16 - format: Some(maudit::assets::ImageFormat::Webp), 16 + format: Some(maudit::assets::ImageFormat::WebP), 17 17 }, 18 18 ); 19 19
+1 -1
examples/kitchen-sink/src/pages/dynamic.rs
··· 11 11 } 12 12 13 13 impl Page<Params> for DynamicExample { 14 - fn routes(&self, _: &mut DynamicRouteContext) -> Vec<Route<Params>> { 14 + fn routes(&self, _: &DynamicRouteContext) -> Routes<Params> { 15 15 (0..1) 16 16 .map(|i| Route::from_params(Params { page: i })) 17 17 .collect()
+4
examples/library/.gitignore
··· 1 + target 2 + dist 3 + node_modules 4 + .DS_Store
+13
examples/library/Cargo.toml
··· 1 + [package] 2 + name = "maudit-example-library" 3 + version = "0.1.0" 4 + edition = "2021" 5 + publish = false 6 + 7 + [package.metadata.maudit] 8 + intended_version = "0.5.0" 9 + 10 + [dependencies] 11 + maudit = { workspace = true } 12 + maud = "0.26.0" 13 + serde = { version = "1.0.216" }
+29
examples/library/content/articles/first-post.md
··· 1 + --- 2 + title: First Post 3 + description: This is the first post on the blog. 4 + --- 5 + 6 + ## Section 1: Formatting Text 7 + 8 + ### Bold and Italic 9 + 10 + - **Bold Text** makes things stand out. 11 + - _Italic Text_ gives emphasis to words. 12 + - **_Bold and Italic_** combined for maximum emphasis. 13 + 14 + ### Mixed Formatting 15 + 16 + - **This is bold and _italic_** to combine styles. 17 + - _This is italic and **bold**_ as well. 18 + 19 + ## Section 2: Links 20 + 21 + You can include links like this: 22 + 23 + - [Check out the Markdown Guide](https://www.markdownguide.org/) 24 + 25 + ## Section 3: Combining All Features 26 + 27 + Here’s a sentence that combines everything: 28 + 29 + - **_Check out the [Markdown Guide](https://www.markdownguide.org/) for more tips_**.
+6
examples/library/content/articles/second-post.md
··· 1 + --- 2 + title: Second Post 3 + description: This is the second post on the blog. 4 + --- 5 + 6 + This is another post on the blog!
+6
examples/library/content/articles/third-post.md
··· 1 + --- 2 + title: Third Post 3 + description: This is the third post on the blog. 4 + --- 5 + 6 + Well, another post is here.
+1
examples/library/images/logo.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="357.3" height="281" fill="none"><path fill="#0d0d0d" d="M303 267c-51-6-51-6-83-5h-32l-63 7a1419 1419 0 0 0-92 12L22 173 12 66l-3-1C3 63-1 55 0 46c0-5 1-6 6-9 5-4 5-4 11-3 5 0 7 1 9 3 3 3 3 3 4 12 0 8 0 10-2 12l-2 2 15 19c22 27 58 67 59 66l4-15a1924 1924 0 0 1 13-64l-4-8c-3-3-7-11-9-16l-3-10 6-7c6-6 6-7 13-8 9-3 13-1 18 5 5 8 5 11 1 23l-4 12 7 21c7 19 7 19 27 52 20 32 25 39 26 36l3-14 14-60 10-48c0-2-2-3-5-6l-5-7 1-6c1-6 2-7 7-11 4-2 6-3 10-2 6 0 8 1 12 7 4 7 5 15 3 19l-6 5-5 3c0 5 48 117 49 117 4 0 5-5 18-63l13-59-6-4-5-3-1-10-1-10 6-8 6-8 8-1c8 0 8 0 13 3s6 4 7 11c2 8 2 15 0 18l-8 6-6 4 8 34 8 34 8 81c7 80 7 81 5 81z"/></svg>
+125
examples/library/src/build.rs
··· 1 + use std::path::PathBuf; 2 + use std::str::FromStr; 3 + use std::{collections::HashSet, fs}; 4 + 5 + use maudit::page::DynamicRouteContext; 6 + use maudit::{ 7 + assets::PageAssets, 8 + content::{ContentSources, PageContent}, 9 + page::{FullPage, RouteContext, RouteParams, RouteType}, 10 + BuildOptions, 11 + }; 12 + 13 + pub fn build_website( 14 + routes: &[&dyn FullPage], 15 + mut content_sources: ContentSources, 16 + options: BuildOptions, 17 + ) -> Result<(), Box<dyn std::error::Error>> { 18 + let dist_dir = PathBuf::from_str(&options.output_dir)?; 19 + 20 + // Initialize all the content sources; 21 + content_sources.init_all(); 22 + 23 + let mut all_assets: HashSet<(PathBuf, PathBuf)> = HashSet::new(); 24 + 25 + for route in routes { 26 + match route.route_type() { 27 + RouteType::Static => { 28 + // Our page does not include content or assets, but we'll set those up for future use. 29 + let content = PageContent::new(&content_sources); 30 + let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 31 + 32 + // Static and dynamic routes share the same interface for building, but static routes do not require any parameters. 33 + // As such, we can just pass an empty set of parameters (the default for RouteParams). 34 + let params = RouteParams::default(); 35 + 36 + // Every page has a RouteContext, which contains information about the current route, as well as access to content and assets. 37 + let mut ctx = RouteContext::from_static_route( 38 + &content, 39 + &mut page_assets, 40 + route.url(&params).clone(), 41 + ); 42 + 43 + let content = route.build(&mut ctx)?; 44 + 45 + // FullPage.file_path() returns a path that does not include the output directory, so we need to join it with dist_dir. 46 + let final_filepath = dist_dir.join(route.file_path(&params)); 47 + 48 + // On some platforms, creating a file in a nested directory requires that the directory already exists. 49 + if let Some(parent_dir) = final_filepath.parent() { 50 + fs::create_dir_all(parent_dir)? 51 + } 52 + 53 + fs::write(final_filepath, content)?; 54 + 55 + // Collect all assets used by this page. 56 + all_assets.extend(page_assets.assets().map(|asset| { 57 + ( 58 + dist_dir.join(asset.build_path()), 59 + asset.path().to_path_buf(), 60 + ) 61 + })); 62 + } 63 + RouteType::Dynamic => { 64 + // The `routes` method returns all the possible routes for this page, along with their parameters and properties. 65 + // It is very common for dynamic pages to be based on content, for instance a blog post page that has one route per blog post. 66 + // As such, we create a mini RouteContext that includes the content sources, so that the page can use them to generate its routes. 67 + 68 + let dynamic_ctx = DynamicRouteContext { 69 + content: &PageContent::new(&content_sources), 70 + }; 71 + 72 + let routes = route.routes_internal(&dynamic_ctx); 73 + 74 + // Every page can share the same PageContent instance, as it is just a view into the content sources. 75 + let content = PageContent::new(&content_sources); 76 + 77 + for dynamic_route in routes { 78 + // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route. 79 + // This is especially relevant if we were to parallelize this loop in the future. 80 + let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 81 + 82 + // The dynamic route includes the parameters for this specific route. 83 + let params = &dynamic_route.0; 84 + 85 + // Here the context is created from a dynamic route, as the context has to include the route parameters and properties. 86 + let mut ctx = RouteContext::from_dynamic_route( 87 + &dynamic_route, 88 + &content, 89 + &mut page_assets, 90 + route.url(params), 91 + ); 92 + 93 + // Everything from here is the same as for static routes. 94 + let content = route.build(&mut ctx)?; 95 + 96 + let final_file_path = &dist_dir.join(route.file_path(params)); 97 + 98 + if let Some(parent_dir) = final_file_path.parent() { 99 + fs::create_dir_all(parent_dir)? 100 + } 101 + 102 + fs::write(final_file_path, content)?; 103 + 104 + // Collect all assets used by this page. 105 + all_assets.extend(page_assets.assets().map(|asset| { 106 + ( 107 + dist_dir.join(asset.build_path()), 108 + asset.path().to_path_buf(), 109 + ) 110 + })); 111 + } 112 + } 113 + } 114 + } 115 + 116 + // Copy all assets to the output directory. 117 + for (dest_path, src_path) in all_assets { 118 + if let Some(parent) = dest_path.parent() { 119 + fs::create_dir_all(parent)?; 120 + } 121 + fs::copy(src_path, dest_path)?; 122 + } 123 + 124 + Ok(()) 125 + }
+7
examples/library/src/content.rs
··· 1 + use maudit::content::markdown_entry; 2 + 3 + #[markdown_entry] 4 + pub struct ArticleContent { 5 + pub title: String, 6 + pub description: String, 7 + }
+23
examples/library/src/layout.rs
··· 1 + use maud::{html, Markup, PreEscaped}; 2 + 3 + pub fn layout(content: String) -> Markup { 4 + html! { 5 + html { 6 + head { 7 + meta charset="utf-8"; 8 + title { "My Blog" } 9 + } 10 + body { 11 + header { 12 + h1 { "My Blog" } 13 + } 14 + main { 15 + (PreEscaped(content)) 16 + } 17 + footer { 18 + p { "© 2024 My Super Blog" } 19 + } 20 + } 21 + } 22 + } 23 + }
+25
examples/library/src/main.rs
··· 1 + mod content; 2 + mod layout; 3 + use content::ArticleContent; 4 + use maudit::{content::glob_markdown, content_sources, routes, BuildOptions}; 5 + 6 + use crate::build::build_website; 7 + 8 + mod pages { 9 + mod article; 10 + mod index; 11 + pub use article::Article; 12 + pub use index::Index; 13 + } 14 + 15 + mod build; 16 + 17 + fn main() -> Result<(), Box<dyn std::error::Error>> { 18 + build_website( 19 + routes![pages::Index], 20 + content_sources![ 21 + "articles" => glob_markdown::<ArticleContent>("content/articles/*.md", None) 22 + ], 23 + BuildOptions::default(), 24 + ) 25 + }
+34
examples/library/src/pages/article.rs
··· 1 + use maudit::page::prelude::*; 2 + 3 + use crate::{content::ArticleContent, layout::layout}; 4 + 5 + #[route("/articles/[article]")] 6 + pub struct Article; 7 + 8 + #[derive(Params, Clone)] 9 + pub struct ArticleParams { 10 + pub article: String, 11 + } 12 + 13 + impl Page<ArticleParams> for Article { 14 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<ArticleParams> { 15 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 16 + 17 + articles.into_routes(|entry| { 18 + Route::from_params(ArticleParams { 19 + article: entry.id.clone(), 20 + }) 21 + }) 22 + } 23 + 24 + fn render(&self, ctx: &mut RouteContext) -> RenderResult { 25 + let params = ctx.params::<ArticleParams>(); 26 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 27 + let article = articles.get_entry(&params.article); 28 + 29 + let headings = article.data(ctx).get_headings(); 30 + println!("{:?}", headings); 31 + 32 + layout(article.render(ctx)).into() 33 + } 34 + }
+35
examples/library/src/pages/index.rs
··· 1 + use maud::html; 2 + use maudit::page::prelude::*; 3 + 4 + use crate::{ 5 + content::ArticleContent, 6 + layout::layout, 7 + pages::{article::ArticleParams, Article}, 8 + }; 9 + 10 + #[route("/")] 11 + pub struct Index; 12 + 13 + impl Page for Index { 14 + fn render(&self, ctx: &mut RouteContext) -> RenderResult { 15 + let articles = ctx.content.get_source::<ArticleContent>("articles"); 16 + let logo = ctx.assets.add_image("images/logo.svg"); 17 + 18 + let markup = html! { 19 + (logo) 20 + ul { 21 + @for entry in &articles.entries { 22 + li { 23 + a href=(get_page_url(&Article, ArticleParams { article: entry.id.clone() })) { 24 + h2 { (entry.data(ctx).title) } 25 + } 26 + p { (entry.data(ctx).description) } 27 + } 28 + } 29 + } 30 + } 31 + .into_string(); 32 + 33 + layout(markup).into() 34 + } 35 + }
+248
website/content/docs/library.md
··· 1 + --- 2 + title: "Maudit as a library" 3 + description: "Learn how to use Maudit as a library in your Rust projects." 4 + section: "advanced" 5 + --- 6 + 7 + [Maudit is built as a library, not a framework](/docs/philosophy/#maudit-is-a-library-not-a-framework). It is absolutely primordial to us that Maudit does not feel like this black box that you cannot inspect or understand. You should be able to see how everything works, and make it work for you. 8 + 9 + As such, in this guide, we'll be building our own minimal [entrypoint](/docs/entrypoint/) to replace the built-in [`coronate`](https://docs.rs/maudit/latest/maudit/fn.coronate.html) function. This will give us a better understanding of how Maudit works, and allow us to customize it to our needs. 10 + 11 + > The result of this guide is available in the [library example](https://github.com/bruits/maudit/tree/main/examples/library) in the Maudit repository. 12 + 13 + ## Setting up the project 14 + 15 + We'll start by creating a new Rust project with Maudit as a dependency: 16 + 17 + ```bash 18 + cargo new library --bin 19 + cd library 20 + cargo add maudit 21 + ``` 22 + 23 + and we'll create a simple Maudit page in `src/pages/index.rs`: 24 + 25 + ```rs 26 + use maudit::page::prelude::*; 27 + 28 + #[route("/")] 29 + pub struct Index; 30 + 31 + impl Page for Index { 32 + fn render(&self, _: &mut RouteContext) -> RenderResult { 33 + "Hello, Maudit!".into() 34 + } 35 + } 36 + ``` 37 + 38 + We'll now start building our own entrypoint in `src/build.rs`, which will contain a `build_website` function, taking the same parameters as `coronate`: 39 + 40 + ```rs 41 + use maudit::page::FullPage; 42 + use maudit::{content::ContentSources, BuildOptions}; 43 + 44 + pub fn build_website( 45 + routes: &[&dyn FullPage], 46 + mut content_sources: ContentSources, 47 + options: BuildOptions, 48 + ) -> Result<(), Box<dyn std::error::Error>> { 49 + // Implementation will go here 50 + 51 + Ok(()) 52 + } 53 + ``` 54 + 55 + Finally, we'll modify `src/main.rs` to call our `build_website` function: 56 + 57 + ```rs 58 + mod build; 59 + 60 + mod pages { 61 + mod index; 62 + pub use index::Index; 63 + } 64 + 65 + fn main() { 66 + let _ = build_website( 67 + routes![Index], 68 + content_sources![], 69 + BuildOptions::default(), 70 + ); 71 + } 72 + ``` 73 + 74 + Now that we have our project set up, let's implement the `build_website` function step by step. 75 + 76 + ## Building pages 77 + 78 + The first step in building our own entrypoint is to iterate over the routes and build each page. Routes can either be static (i.e. `/index`) or dynamic (i.e. `/articles/[id]`). For now, we'll only handle static routes. 79 + 80 + ```rs 81 + use std::fs; 82 + use std::path::PathBuf; 83 + use std::str::FromStr; 84 + 85 + use maudit::{ 86 + assets::PageAssets, 87 + content::{ContentSources, PageContent}, 88 + page::{FullPage, RouteContext, RouteParams, RouteType}, 89 + BuildOptions, 90 + }; 91 + 92 + pub fn build_website( 93 + routes: &[&dyn FullPage], 94 + mut content_sources: ContentSources, 95 + options: BuildOptions, 96 + ) -> Result<(), Box<dyn std::error::Error>> { 97 + let dist_dir = PathBuf::from_str(&options.output_dir)?; 98 + 99 + for route in routes { 100 + match route.route_type() { 101 + RouteType::Static => { 102 + // Our page does not include content or assets, but we'll set those up for future use. 103 + let content = PageContent::new(&content_sources); 104 + let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 105 + 106 + // Static and dynamic routes share the same interface for building, but static routes do not require any parameters. 107 + // As such, we can just pass an empty set of parameters (the default for RouteParams). 108 + let params = RouteParams::default(); 109 + 110 + // Every page has a RouteContext, which contains information about the current route, as well as access to content and assets. 111 + let mut ctx = RouteContext::from_static_route( 112 + &content, 113 + &mut page_assets, 114 + route.url(&params).clone(), 115 + ); 116 + 117 + let content = route.build(&mut ctx)?; 118 + 119 + // FullPage.file_path() returns a path that does not include the output directory, so we need to join it with dist_dir. 120 + let final_filepath = dist_dir.join(route.file_path(&params)); 121 + 122 + // On some platforms, creating a file in a nested directory requires that the directory already exists or `fs::write` will fail. 123 + if let Some(parent_dir) = final_filepath.parent() { 124 + fs::create_dir_all(parent_dir)? 125 + } 126 + 127 + fs::write(final_filepath, content)?; 128 + } 129 + RouteType::Dynamic => { 130 + unimplemented!("We'll handle dynamic routes later"); 131 + } 132 + } 133 + } 134 + 135 + Ok(()) 136 + } 137 + ``` 138 + 139 + And with just this code, we can already build our first page! Running `cargo run` should create a `dist/index.html` file with the content `Hello, Maudit!`. But, if you try to use assets, you'll notice that they are not copied to the output directory. And similarly, if you try to use content, you'll notice that it'll always throw an error saying that the content source is not found. Let's fix that! 140 + 141 + ## Handling assets 142 + 143 + We won't be implementing any asset processing (like bundling or minification) in this guide, but we'll be implementing the logic to copy assets from the assets directory to the output directory. 144 + 145 + ```rs 146 + pub fn build_website( 147 + routes: &[&dyn FullPage], 148 + mut content_sources: ContentSources, 149 + options: BuildOptions, 150 + ) -> Result<(), Box<dyn std::error::Error>> { 151 + let dist_dir = PathBuf::from_str(&options.output_dir)?; 152 + 153 + let mut all_assets: HashSet<(PathBuf, PathBuf)> = HashSet::new(); 154 + 155 + for route in routes { 156 + match route.route_type() { 157 + RouteType::Static => { 158 + // ... Same as before ... 159 + 160 + // Collect all assets used by this page. 161 + all_assets.extend(page_assets.assets().map(|asset| { 162 + ( 163 + dist_dir.join(asset.build_path()), 164 + asset.path().to_path_buf(), 165 + ) 166 + })); 167 + } 168 + RouteType::Dynamic => { 169 + unimplemented!("We'll handle dynamic routes later"); 170 + } 171 + } 172 + } 173 + 174 + // Copy all assets to the output directory. 175 + for (dest_path, src_path) in all_assets { 176 + // Similar to pages, we need to ensure the parent directory exists or `fs::copy` will fail. 177 + if let Some(parent) = dest_path.parent() { 178 + fs::create_dir_all(parent)?; 179 + } 180 + fs::copy(src_path, dest_path)?; 181 + } 182 + 183 + Ok(()) 184 + } 185 + ``` 186 + 187 + This is enough to get us started with assets. Any pages using assets will now have them copied to the output directory. This is a basic implementation, but it is all we need to get assets working. `route.build` already takes care of automatically including scripts and styles in the page, so we don't need to do anything special for that. Additionally, `asset.build_path()` already takes care of adding hashes to filenames in a performant way, so we don't need to worry about that either. 188 + 189 + ## Handling content 190 + 191 + As said previously, our current implementation does not handle content sources. Currently any pages trying to use content would panic with an error saying the requested content source does not exist. To fix this, we need to make sure that the content sources are properly loaded before building the pages. 192 + 193 + If you've copied the previous snippets, you might have noticed that Rust has been complaining about `content_sources` being mutable but never mutated. We'll fix that now by initializing each content source before building the pages: 194 + 195 + ```rs 196 + content_sources.init_all(); 197 + ``` 198 + 199 + That's all. Now, any content source used in a page will be properly loaded and available for use. This is the most straightforward way to initialize content sources, but a more advanced implementation could for instance initialize content sources in parallel, lazily when a page actually requests content from a source or using advanced caching strategies. 200 + 201 + ## Dynamic routes 202 + 203 + A dynamic route is a route that generates multiple pages based on some parameters. For instance, a blog post page might have a dynamic route `/posts/[id]`, where `[id]` is a parameter that can take different values for each blog post. Each individual page is essentially a static route, but it has a slightly different context available to it. 204 + 205 + ```rs 206 + for route in routes { 207 + match route.route_type() { 208 + RouteType::Static => { 209 + // No changes here, same as before. 210 + } 211 + RouteType::Dynamic => { 212 + // The `routes` method returns all the possible routes for this page, along with their parameters and properties. 213 + // It is very common for dynamic pages to be based on content, for instance a blog post page that has one route per blog post. 214 + // As such, we create a mini RouteContext that includes the content sources, so that the page can use them to generate its routes. 215 + 216 + let dynamic_ctx = DynamicRouteContext { 217 + content: &PageContent::new(&content_sources), 218 + }; 219 + 220 + let routes = route.routes_internal(&dynamic_ctx); 221 + 222 + // Every page can share the same PageContent instance, as it is just a view into the content sources. 223 + let content = PageContent::new(&content_sources); 224 + 225 + for dynamic_route in routes { 226 + // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route. 227 + // This is especially relevant if we were to parallelize this loop in the future. 228 + let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 229 + 230 + // The dynamic route includes the parameters for this specific route. 231 + let params = &dynamic_route.0; 232 + 233 + // Here the context is created from a dynamic route, as the context has to include the route parameters and properties. 234 + let mut ctx = RouteContext::from_dynamic_route( 235 + &dynamic_route, 236 + &content, 237 + &mut page_assets, 238 + route.url(params), 239 + ); 240 + 241 + // Everything after this is the same as for static routes, making sure to use the route parameters when getting the file path. 242 + } 243 + } 244 + } 245 + } 246 + ``` 247 + 248 + And with that, you've succesfully rebuilt Maudit at home! There's a few more things that can be done to improve this implementation, like adding logging, copying static assets, asset processing, better error handling, parallelization, caching, etc, etc. But, this is a fully functional implementation that can be used as a starting point for more advanced use cases... or just as a learning exercise to understand how Maudit works under the hood.
+3 -3
website/content/docs/routing.md
··· 67 67 RenderResult::Text(format!("Hello, {}!", params.slug)) 68 68 } 69 69 70 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Routes<Params> { 70 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<Params> { 71 71 vec![Route::from_params(Params { 72 72 slug: "hello-world".to_string(), 73 73 })] ··· 94 94 format!("Hello, {}!", slug) 95 95 } 96 96 97 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Routes<Params> { 97 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<Params> { 98 98 vec![Route::from_params(Params { 99 99 slug: "hello-world".to_string(), 100 100 })] ··· 137 137 } 138 138 139 139 impl Page<Params> for PostJson { 140 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Routes<Params> { 140 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<Params> { 141 141 vec![Route::from_params(Params { 142 142 slug: "hello-world".to_string() 143 143 })]
+1 -1
website/src/pages/docs.rs
··· 45 45 } 46 46 47 47 impl Page<DocsPageParams> for DocsPage { 48 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<Route<DocsPageParams>> { 48 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<DocsPageParams> { 49 49 let content = ctx.content.get_source::<DocsContent>("docs"); 50 50 51 51 content.into_routes(|entry| {
+1 -1
website/src/pages/news.rs
··· 87 87 } 88 88 89 89 impl Page<NewsPageParams> for NewsPage { 90 - fn routes(&self, ctx: &mut DynamicRouteContext) -> Vec<Route<NewsPageParams>> { 90 + fn routes(&self, ctx: &DynamicRouteContext) -> Routes<NewsPageParams> { 91 91 let content = ctx.content.get_source::<NewsContent>("news"); 92 92 93 93 content.into_routes(|entry| {