Rust library to generate static websites
5
fork

Configure Feed

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

feat: experiment with more ergonomic pagination (#42)

* feat: experiment with more ergonomic pagination

* feat: some content cleanup

* fix: try something

* fix: pass theme

authored by

Erika and committed by
GitHub
95fa98b1 90cef9f4

+324 -248
+2 -2
crates/maudit-macros/src/lib.rs
··· 74 74 quote! { 75 75 map.insert( 76 76 #field_name_str.to_string(), 77 - self.#field_name.as_ref().map_or("__MAUDIT_NONE__".to_string(), |v| v.to_string()) 77 + self.#field_name.as_ref().map(|v| v.to_string()) 78 78 ); 79 79 } 80 80 } else { 81 81 quote! { 82 - map.insert(#field_name_str.to_string(), self.#field_name.to_string()); 82 + map.insert(#field_name_str.to_string(), Some(self.#field_name.to_string())); 83 83 } 84 84 } 85 85 })
-1
crates/maudit/src/assets/script.rs
··· 6 6 }; 7 7 8 8 #[derive(Clone, PartialEq, Eq, Hash)] 9 - #[non_exhaustive] 10 9 pub struct Script { 11 10 pub path: PathBuf, 12 11 pub(crate) hash: String,
-1
crates/maudit/src/assets/style.rs
··· 11 11 } 12 12 13 13 #[derive(Clone, PartialEq, Eq, Hash)] 14 - #[non_exhaustive] 15 14 pub struct Style { 16 15 pub path: PathBuf, 17 16 pub(crate) hash: String,
+2 -2
crates/maudit/src/build/metadata.rs
··· 7 7 pub struct PageOutput { 8 8 pub route: String, 9 9 pub file_path: String, 10 - pub params: Option<FxHashMap<String, String>>, 10 + pub params: Option<FxHashMap<String, Option<String>>>, 11 11 } 12 12 13 13 /// Metadata returned by [`coronate()`](crate::coronate) for a single static asset after a successful build. ··· 42 42 &mut self, 43 43 route: String, 44 44 file_path: String, 45 - params: Option<FxHashMap<String, String>>, 45 + params: Option<FxHashMap<String, Option<String>>>, 46 46 ) { 47 47 self.pages.push(PageOutput { 48 48 route,
+51 -44
crates/maudit/src/content.rs
··· 1 1 //! Core functions and structs to define the content sources of your website. 2 2 //! 3 3 //! Content sources represent the content of your website, such as articles, blog posts, etc. Then, content sources can be passed to [`coronate()`](crate::coronate), through the [`content_sources!`](crate::content_sources) macro, to be loaded. 4 - use std::{any::Any, path::PathBuf}; 4 + use std::{any::Any, path::PathBuf, sync::Arc}; 5 5 6 6 use rustc_hash::FxHashMap; 7 7 ··· 223 223 /// impl Route for Article { 224 224 /// fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 225 225 /// let articles = ctx.content.get_source::<ArticleContent>("articles"); 226 - /// let article = articles.get_entry("my-article"); // returns a ContentEntry 226 + /// let article = articles.get_entry("my-article"); // returns a Entry<ArticleContent> 227 227 /// article.render(ctx).into() 228 228 /// } 229 229 /// } 230 230 /// ``` 231 - pub struct ContentEntry<T> { 231 + pub struct EntryInner<T> { 232 232 pub id: String, 233 233 render: OptionalContentRenderFn, 234 234 pub raw_content: Option<String>, ··· 237 237 pub file_path: Option<PathBuf>, 238 238 } 239 239 240 + /// Helper type for easier usage of `EntryInner`. Content sources always return Arc-wrapped entries, but the user ergonomics of writing `Arc<EntryInner<T>>` is not great. 241 + pub type Entry<T> = Arc<EntryInner<T>>; 242 + 243 + pub trait ContentEntry<T> { 244 + fn create( 245 + id: String, 246 + render: OptionalContentRenderFn, 247 + raw_content: Option<String>, 248 + data: T, 249 + file_path: Option<PathBuf>, 250 + ) -> Entry<T> { 251 + Arc::new(EntryInner { 252 + id, 253 + render, 254 + raw_content, 255 + data_loader: None, 256 + cached_data: std::sync::OnceLock::from(data), 257 + file_path, 258 + }) 259 + } 260 + 261 + fn create_lazy( 262 + id: String, 263 + render: OptionalContentRenderFn, 264 + raw_content: Option<String>, 265 + data_loader: DataLoadingFn<T>, 266 + file_path: Option<PathBuf>, 267 + ) -> Entry<T> { 268 + Arc::new(EntryInner { 269 + id, 270 + render, 271 + raw_content, 272 + data_loader: Some(data_loader), 273 + cached_data: std::sync::OnceLock::new(), 274 + file_path, 275 + }) 276 + } 277 + } 278 + 279 + impl<T> ContentEntry<T> for Entry<T> {} 280 + 240 281 /// Trait for contexts that can provide access to content 241 282 pub trait ContentContext { 242 283 fn content(&self) -> &RouteContent<'_>; ··· 268 309 type OptionalContentRenderFn = 269 310 Option<Box<dyn Fn(&str, &mut crate::route::PageContext) -> String + Send + Sync>>; 270 311 271 - impl<T> ContentEntry<T> { 272 - pub fn new( 273 - id: String, 274 - render: OptionalContentRenderFn, 275 - raw_content: Option<String>, 276 - data: T, 277 - file_path: Option<PathBuf>, 278 - ) -> Self { 279 - Self { 280 - id, 281 - render, 282 - raw_content, 283 - data_loader: None, 284 - cached_data: std::sync::OnceLock::from(data), 285 - file_path, 286 - } 287 - } 288 - 289 - pub fn new_lazy( 290 - id: String, 291 - render: OptionalContentRenderFn, 292 - raw_content: Option<String>, 293 - data_loader: DataLoadingFn<T>, 294 - file_path: Option<PathBuf>, 295 - ) -> Self { 296 - Self { 297 - id, 298 - render, 299 - raw_content, 300 - data_loader: Some(data_loader), 301 - cached_data: std::sync::OnceLock::new(), 302 - file_path, 303 - } 304 - } 305 - 312 + impl<T> EntryInner<T> { 306 313 pub fn data<C: ContentContext>(&self, ctx: &mut C) -> &T { 307 314 self.cached_data.get_or_init(|| { 308 315 if let Some(ref loader) = self.data_loader { ··· 369 376 } 370 377 } 371 378 372 - type ContentSourceInitMethod<T> = Box<dyn Fn() -> Vec<ContentEntry<T>> + Send + Sync>; 379 + type ContentSourceInitMethod<T> = Box<dyn Fn() -> Vec<Arc<EntryInner<T>>> + Send + Sync>; 373 380 374 381 /// A source of content such as articles, blog posts, etc. 375 382 pub struct ContentSource<T = Untyped> { 376 383 pub name: String, 377 - pub entries: Vec<ContentEntry<T>>, 384 + pub entries: Vec<Arc<EntryInner<T>>>, 378 385 pub(crate) init_method: ContentSourceInitMethod<T>, 379 386 } 380 387 ··· 390 397 } 391 398 } 392 399 393 - pub fn get_entry(&self, id: &str) -> &ContentEntry<T> { 400 + pub fn get_entry(&self, id: &str) -> &Entry<T> { 394 401 self.entries 395 402 .iter() 396 403 .find(|entry| entry.id == id) 397 404 .unwrap_or_else(|| panic!("Entry with id '{}' not found", id)) 398 405 } 399 406 400 - pub fn get_entry_safe(&self, id: &str) -> Option<&ContentEntry<T>> { 407 + pub fn get_entry_safe(&self, id: &str) -> Option<&Entry<T>> { 401 408 self.entries.iter().find(|entry| entry.id == id) 402 409 } 403 410 404 - pub fn into_params<P>(&self, cb: impl Fn(&ContentEntry<T>) -> P) -> Vec<P> 411 + pub fn into_params<P>(&self, cb: impl Fn(&Entry<T>) -> P) -> Vec<P> 405 412 where 406 413 P: Into<PageParams>, 407 414 { ··· 410 417 411 418 pub fn into_pages<Params, Props>( 412 419 &self, 413 - cb: impl Fn(&ContentEntry<T>) -> crate::route::Page<Params, Props>, 420 + cb: impl Fn(&Entry<T>) -> crate::route::Page<Params, Props>, 414 421 ) -> crate::route::Pages<Params, Props> 415 422 where 416 423 Params: Into<PageParams>,
+16 -4
crates/maudit/src/content/highlight.rs
··· 1 + use core::panic; 1 2 use std::sync::OnceLock; 2 3 use syntect::{ 3 4 Error, ··· 73 74 (Self { meta }, opening_html) 74 75 } 75 76 76 - pub fn highlight(&self, content: &str) -> Result<String, Error> { 77 + pub fn highlight(&self, content: &str, theme_path: &str) -> Result<String, Error> { 77 78 let ss = get_syntax_set(); 78 79 let ts = get_theme_set(); 79 80 80 81 let syntax = ss 81 - .find_syntax_by_name(&self.meta.language) 82 + .find_syntax_by_token(&self.meta.language) 83 + // Maybe token is enough, looking around at other users of Syntect, it seems like they often just use by_token, not sure. 84 + .or_else(|| ss.find_syntax_by_name(&self.meta.language)) 82 85 .or_else(|| ss.find_syntax_by_extension(&self.meta.language)) 83 86 .or_else(|| ss.find_syntax_by_first_line(content)) 84 87 .unwrap_or_else(|| ss.find_syntax_plain_text()); 85 88 86 - // TODO: Allow configuring the theme 87 - let mut h = HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]); 89 + let theme = match ts.themes.get(theme_path) { 90 + Some(theme) => theme, 91 + None => &match ThemeSet::get_theme(theme_path) { 92 + Ok(theme) => theme, 93 + Err(_) => panic!( 94 + "Theme '{theme_path}' not found in default themes and could not be loaded from file." 95 + ), 96 + }, 97 + }; 98 + 99 + let mut h = HighlightLines::new(syntax, theme); 88 100 89 101 let mut highlighted = String::new(); 90 102 for line in LinesWithEndings::from(content) {
+24 -7
crates/maudit/src/content/markdown.rs
··· 13 13 use crate::{ 14 14 assets::Asset, 15 15 content::{ 16 - ContentContext, 16 + ContentContext, ContentEntry, Entry, 17 17 shortcodes::{MarkdownShortcodes, preprocess_shortcodes}, 18 18 }, 19 19 route::PageContext, 20 20 }; 21 21 22 - use super::{ContentEntry, highlight::CodeBlock, slugger}; 22 + use super::{highlight::CodeBlock, slugger}; 23 23 24 24 #[cfg(test)] 25 25 mod shortcodes_tests; ··· 146 146 } 147 147 } 148 148 149 - #[derive(Default)] 150 149 pub struct MarkdownOptions { 150 + pub highlight_theme: String, 151 151 pub components: MarkdownComponents, 152 152 pub shortcodes: MarkdownShortcodes, 153 + } 154 + 155 + impl Default for MarkdownOptions { 156 + fn default() -> Self { 157 + Self { 158 + highlight_theme: "base16-ocean.dark".to_string(), 159 + components: MarkdownComponents::default(), 160 + shortcodes: MarkdownShortcodes::default(), 161 + } 162 + } 153 163 } 154 164 155 165 impl MarkdownOptions { ··· 161 171 Self { 162 172 components, 163 173 shortcodes, 174 + ..Self::default() 164 175 } 165 176 } 166 177 } 167 178 168 - /// Glob for Markdown files and return a vector of [`ContentEntry`]s. 179 + /// Glob for Markdown files and return a vector of [`Entry`]s. 169 180 /// 170 181 /// Typically used by [`content_sources!`](crate::content_sources) to define a Markdown content source in [`coronate()`](crate::coronate). 171 182 /// ··· 190 201 /// ) 191 202 /// } 192 203 /// ``` 193 - pub fn glob_markdown<T>(pattern: &str, options: Option<MarkdownOptions>) -> Vec<ContentEntry<T>> 204 + pub fn glob_markdown<T>(pattern: &str, options: Option<MarkdownOptions>) -> Vec<Entry<T>> 194 205 where 195 206 T: DeserializeOwned + MarkdownContent + InternalMarkdownContent + Send + Sync + 'static, 196 207 { 197 208 let mut entries = vec![]; 198 209 let options = options.map(Arc::new); 199 210 211 + // TODO: `glob` is kinda slow, but alternatives are either unmaintained, have annoying bugs or not faster. 200 212 for entry in glob_fs(pattern).unwrap() { 201 213 let entry = entry.unwrap(); 202 214 ··· 221 233 let opts = options.clone(); 222 234 let path = entry.clone(); 223 235 224 - entries.push(ContentEntry::new_lazy( 236 + entries.push(Entry::create_lazy( 225 237 id, 226 238 Some(Box::new(move |content: &str, route_ctx| { 227 239 render_markdown(content, opts.as_deref(), Some(&path), Some(route_ctx)) ··· 399 411 400 412 Event::End(TagEnd::CodeBlock) => { 401 413 if let Some(ref mut code_block) = code_block { 402 - let html = code_block.highlight(&code_block_content); 414 + let html = code_block.highlight( 415 + &code_block_content, 416 + &options 417 + .unwrap_or(&MarkdownOptions::default()) 418 + .highlight_theme, 419 + ); 403 420 events[i] = 404 421 Event::Html(format!("{}{}", html.unwrap(), "</code></pre>\n").into()); 405 422 }
+195 -171
crates/maudit/src/route.rs
··· 3 3 //! Every route must implement the [`Route`] trait. Then, pages can be passed to [`coronate()`](crate::coronate), through the [`routes!`](crate::routes) macro, to be built. 4 4 use crate::assets::RouteAssets; 5 5 use crate::build::finish_route; 6 - use crate::content::RouteContent; 6 + use crate::content::{Entry, RouteContent}; 7 7 use crate::routing::{ 8 8 extract_params_from_raw_route, get_route_type_from_route_params, guess_if_route_is_endpoint, 9 9 }; ··· 111 111 } 112 112 113 113 /// Pagination page for any type of items 114 - pub struct PaginationPage<'a, T> { 114 + #[derive(Clone)] 115 + pub struct PaginationPage<T> { 115 116 pub page: usize, 116 117 pub per_page: usize, 117 118 pub total_items: usize, ··· 122 123 pub prev_page: Option<usize>, 123 124 pub start_index: usize, 124 125 pub end_index: usize, 125 - pub items: &'a [T], 126 + pub items: Vec<T>, 126 127 } 127 128 128 - impl<'a, T> PaginationPage<'a, T> { 129 - pub fn new(page: usize, per_page: usize, total_items: usize, items: &'a [T]) -> Self { 129 + impl<T> PaginationPage<T> { 130 + pub fn new(page: usize, per_page: usize, total_items: usize, page_items: Vec<T>) -> Self { 130 131 let total_pages = if total_items == 0 { 131 132 1 132 133 } else { ··· 150 151 prev_page: if page > 0 { Some(page - 1) } else { None }, 151 152 start_index, 152 153 end_index, 153 - items: &items[start_index..end_index], 154 + items: page_items, 154 155 } 155 156 } 156 157 } 157 158 158 - impl<'a, T> std::fmt::Debug for PaginationPage<'a, T> { 159 + impl<T> std::fmt::Debug for PaginationPage<T> { 159 160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 160 161 f.debug_struct("PaginationPage") 161 162 .field("page", &self.page) ··· 174 175 } 175 176 } 176 177 177 - /// Helper function to create paginated routes from any slice 178 - pub fn paginate<T, Params>( 179 - items: &[T], 178 + /// Type alias for pagination pages of content entries, for easier usage 179 + pub type PaginatedContentPage<T> = PaginationPage<Entry<T>>; 180 + 181 + /// Helper function to create paginated routes from any iterator 182 + pub fn paginate<T, I, Params>( 183 + items: I, 180 184 per_page: usize, 181 185 mut params_fn: impl FnMut(usize) -> Params, 182 - ) -> Pages<Params, PaginationPage<'_, T>> 186 + ) -> Pages<Params, PaginationPage<T>> 183 187 where 188 + I: IntoIterator<Item = T>, 184 189 Params: Into<PageParams>, 190 + T: Clone, 185 191 { 192 + let items: Vec<T> = items.into_iter().collect(); 193 + 186 194 if items.is_empty() { 187 195 return vec![]; 188 196 } ··· 193 201 194 202 for page in 0..total_pages { 195 203 let params = params_fn(page); 196 - let props = PaginationPage::new(page, per_page, total_items, items); 204 + 205 + // Calculate the slice for this specific page 206 + let start_index = page * per_page; 207 + let end_index = ((page + 1) * per_page).min(total_items); 208 + let page_items = items[start_index..end_index].to_vec(); 209 + 210 + let props = PaginationPage::new(page, per_page, total_items, page_items); 197 211 198 212 routes.push(Page::new(params, props)); 199 213 } ··· 374 388 /// 375 389 /// Can be accessed through [`PageContext`]'s `raw_params`. 376 390 #[derive(Clone, Default, Debug)] 377 - pub struct PageParams(pub FxHashMap<String, String>); 391 + pub struct PageParams(pub FxHashMap<String, Option<String>>); 378 392 379 393 impl PageParams { 380 394 pub fn from_vec<T>(params: Vec<T>) -> Vec<PageParams> ··· 441 455 let value = params.0.get(&param_def.key); 442 456 443 457 match value { 444 - Some(value) => { 445 - route.replace_range(param_def.index..param_def.index + param_def.length, value); 446 - } 458 + Some(value) => match value { 459 + Some(value) => { 460 + route.replace_range( 461 + param_def.index..param_def.index + param_def.length, 462 + value, 463 + ); 464 + } 465 + None => { 466 + route 467 + .replace_range(param_def.index..param_def.index + param_def.length, ""); 468 + } 469 + }, 447 470 None => { 448 471 panic!( 449 472 "Route {:?} is missing parameter {:?}", ··· 454 477 } 455 478 } 456 479 457 - route 480 + // Collapse multiple slashes into single slashes 481 + route.replace("//", "/") 458 482 } 459 483 460 484 fn file_path(&self, params: &PageParams, output_dir: &Path) -> PathBuf { 461 - let mut params_def = extract_params_from_raw_route(&self.route_raw()); 462 - let mut route = self.route_raw(); 485 + let params_def = extract_params_from_raw_route(&self.route_raw()); 486 + let route_template = self.route_raw(); 463 487 464 - // Sort params by index in reverse order to avoid index shifting issues 465 - params_def.sort_by(|a, b| b.index.cmp(&a.index)); 488 + let mut sorted_params = params_def; 489 + sorted_params.sort_by_key(|p| p.index); 466 490 467 - for param_def in params_def { 468 - let value = params.0.get(&param_def.key); 491 + let mut path = PathBuf::from(output_dir); 492 + let mut last_index = 0; 493 + let mut current_component = String::new(); 469 494 470 - match value { 471 - Some(value) => { 472 - route.replace_range(param_def.index..param_def.index + param_def.length, value); 473 - } 474 - None => { 475 - panic!( 476 - "Route {:?} is missing parameter {:?}", 477 - self.route_raw(), 478 - param_def.key 479 - ); 480 - } 495 + for param_def in sorted_params.iter() { 496 + // Push everything before this param into current_component 497 + current_component.push_str(&route_template[last_index..param_def.index]); 498 + 499 + // Append param value if present 500 + let value = params.0.get(&param_def.key).unwrap_or_else(|| { 501 + panic!( 502 + "Route {:?} is missing parameter {:?}", 503 + route_template, param_def.key 504 + ) 505 + }); 506 + if let Some(v) = value { 507 + current_component.push_str(v); 481 508 } 509 + 510 + last_index = param_def.index + param_def.length; 482 511 } 483 512 484 - let cleaned_raw_route = route.trim_start_matches('/').to_string(); 513 + // Append remainder of the route 514 + current_component.push_str(&route_template[last_index..]); 515 + 516 + // Split by '/' and push non-empty components into the PathBuf 517 + for part in current_component.split('/').filter(|s| !s.is_empty()) { 518 + path.push(part); 519 + } 485 520 486 - output_dir.join(match self.is_endpoint() { 487 - true => cleaned_raw_route, 488 - false => match cleaned_raw_route.is_empty() { 489 - true => "index.html".into(), 490 - false => format!("{}/index.html", cleaned_raw_route), 491 - }, 492 - }) 521 + // Handle endpoint vs. page 522 + if !self.is_endpoint() { 523 + path.push("index.html"); 524 + } 525 + 526 + path 493 527 } 494 528 } 495 529 ··· 555 589 //! use maudit::route::prelude::*; 556 590 //! ``` 557 591 pub use super::{ 558 - DynamicRouteContext, FullRoute, Page, PageContext, PageParams, Pages, PaginationPage, 559 - RenderResult, Route, RouteExt, paginate, 592 + DynamicRouteContext, FullRoute, Page, PageContext, PageParams, Pages, PaginatedContentPage, 593 + PaginationPage, RenderResult, Route, RouteExt, paginate, 594 + }; 595 + pub use crate::assets::{Asset, Image, ImageFormat, ImageOptions, Script, Style, StyleOptions}; 596 + pub use crate::content::{ 597 + ContentContext, ContentEntry, Entry, EntryInner, MarkdownContent, RouteContent, 560 598 }; 561 - pub use crate::assets::{Asset, Image, Style, StyleOptions}; 562 - pub use crate::content::MarkdownContent; 563 599 pub use maudit_macros::{Params, route}; 564 600 } 565 601 ··· 587 623 }; 588 624 589 625 let mut params = FxHashMap::default(); 590 - params.insert("slug".to_string(), "hello-world".to_string()); 626 + params.insert("slug".to_string(), Some("hello-world".to_string())); 591 627 let route_params = PageParams(params); 592 628 593 629 assert_eq!(page.url(&route_params), "/articles/hello-world"); ··· 600 636 }; 601 637 602 638 let mut params = FxHashMap::default(); 603 - params.insert("tag".to_string(), "rust".to_string()); 604 - params.insert("page".to_string(), "2".to_string()); 639 + params.insert("tag".to_string(), Some("rust".to_string())); 640 + params.insert("page".to_string(), Some("2".to_string())); 605 641 let route_params = PageParams(params); 606 642 607 643 assert_eq!(page.url(&route_params), "/articles/tags/rust/2"); ··· 616 652 }; 617 653 618 654 let mut params = FxHashMap::default(); 619 - params.insert("tag".to_string(), "development-experience".to_string()); // Long replacement 620 - params.insert("page".to_string(), "1".to_string()); // Short replacement 655 + params.insert( 656 + "tag".to_string(), 657 + Some("development-experience".to_string()), 658 + ); // Long replacement 659 + params.insert("page".to_string(), Some("1".to_string())); // Short replacement 621 660 let route_params = PageParams(params); 622 661 623 662 assert_eq!( ··· 644 683 }; 645 684 646 685 let mut params = FxHashMap::default(); 647 - params.insert("lang".to_string(), "en".to_string()); 686 + params.insert("lang".to_string(), Some("en".to_string())); 648 687 let route_params = PageParams(params); 649 688 650 689 assert_eq!(page.url(&route_params), "/en/about"); ··· 657 696 }; 658 697 659 698 let mut params = FxHashMap::default(); 660 - params.insert("id".to_string(), "123".to_string()); 699 + params.insert("id".to_string(), Some("123".to_string())); 661 700 let route_params = PageParams(params); 662 701 663 702 assert_eq!(page.url(&route_params), "/api/users/123"); ··· 670 709 }; 671 710 672 711 let mut params = FxHashMap::default(); 673 - params.insert("slug".to_string(), "hello-world".to_string()); 712 + params.insert("slug".to_string(), Some("hello-world".to_string())); 674 713 let route_params = PageParams(params); 675 714 676 715 let output_dir = Path::new("/dist"); ··· 686 725 }; 687 726 688 727 let mut params = FxHashMap::default(); 689 - params.insert("tag".to_string(), "rust".to_string()); 690 - params.insert("page".to_string(), "2".to_string()); 728 + params.insert("tag".to_string(), Some("rust".to_string())); 729 + params.insert("page".to_string(), Some("2".to_string())); 691 730 let route_params = PageParams(params); 692 731 693 732 let output_dir = Path::new("/dist"); ··· 743 782 }; 744 783 745 784 let mut params = FxHashMap::default(); 746 - params.insert("page".to_string(), "1".to_string()); 785 + params.insert("page".to_string(), Some("1".to_string())); 747 786 let route_params = PageParams(params); 748 787 749 788 let output_dir = Path::new("/dist"); ··· 753 792 } 754 793 755 794 #[test] 756 - fn test_pagination_page_with_entries() { 757 - // Create some mock content entries 758 - use crate::content::ContentEntry; 759 - use std::path::PathBuf; 760 - 761 - let entries = vec![ 762 - ContentEntry::new( 763 - "entry1".to_string(), 764 - None, 765 - Some("content1".to_string()), 766 - (), 767 - Some(PathBuf::from("file1.md")), 768 - ), 769 - ContentEntry::new( 770 - "entry2".to_string(), 771 - None, 772 - Some("content2".to_string()), 773 - (), 774 - Some(PathBuf::from("file2.md")), 775 - ), 776 - ContentEntry::new( 777 - "entry3".to_string(), 778 - None, 779 - Some("content3".to_string()), 780 - (), 781 - Some(PathBuf::from("file3.md")), 782 - ), 783 - ContentEntry::new( 784 - "entry4".to_string(), 785 - None, 786 - Some("content4".to_string()), 787 - (), 788 - Some(PathBuf::from("file4.md")), 789 - ), 790 - ContentEntry::new( 791 - "entry5".to_string(), 792 - None, 793 - Some("content5".to_string()), 794 - (), 795 - Some(PathBuf::from("file5.md")), 796 - ), 797 - ]; 798 - 799 - let pagination = PaginationPage::new(1, 2, 5, &entries); 800 - 801 - assert_eq!(pagination.page, 1); 802 - assert_eq!(pagination.per_page, 2); 803 - assert_eq!(pagination.total_items, 5); 804 - assert_eq!(pagination.total_pages, 3); 805 - assert!(pagination.has_next); 806 - assert!(pagination.has_prev); 807 - assert_eq!(pagination.start_index, 2); 808 - assert_eq!(pagination.end_index, 4); 809 - assert_eq!(pagination.items.len(), 2); 810 - assert_eq!(pagination.items[0].id, "entry3"); 811 - assert_eq!(pagination.items[1].id, "entry4"); 812 - } 813 - 814 - #[test] 815 - fn test_paginate_content_function() { 816 - use crate::content::ContentEntry; 817 - use std::path::PathBuf; 818 - 819 - let entries = vec![ 820 - ContentEntry::new( 821 - "entry1".to_string(), 822 - None, 823 - Some("content1".to_string()), 824 - (), 825 - Some(PathBuf::from("file1.md")), 826 - ), 827 - ContentEntry::new( 828 - "entry2".to_string(), 829 - None, 830 - Some("content2".to_string()), 831 - (), 832 - Some(PathBuf::from("file2.md")), 833 - ), 834 - ContentEntry::new( 835 - "entry3".to_string(), 836 - None, 837 - Some("content3".to_string()), 838 - (), 839 - Some(PathBuf::from("file3.md")), 840 - ), 841 - ]; 842 - 843 - let routes = paginate(&entries, 2, |page| { 844 - let mut params = FxHashMap::default(); 845 - params.insert("page".to_string(), page.to_string()); 846 - PageParams(params) 847 - }); 848 - 849 - assert_eq!(routes.len(), 2); 850 - 851 - // First page 852 - assert_eq!(routes[0].props.page, 0); 853 - assert_eq!(routes[0].props.items.len(), 2); 854 - assert_eq!(routes[0].props.items[0].id, "entry1"); 855 - assert_eq!(routes[0].props.items[1].id, "entry2"); 856 - 857 - // Second page 858 - assert_eq!(routes[1].props.page, 1); 859 - assert_eq!(routes[1].props.items.len(), 1); 860 - assert_eq!(routes[1].props.items[0].id, "entry3"); 861 - } 862 - 863 - #[test] 864 795 fn test_paginate_generic_function() { 865 796 // Test with simple strings 866 797 let tags = vec!["rust", "javascript", "python", "go", "typescript"]; 867 798 868 799 let routes = paginate(&tags, 2, |page| { 869 800 let mut params = FxHashMap::default(); 870 - params.insert("page".to_string(), page.to_string()); 801 + params.insert("page".to_string(), Some(page.to_string())); 871 802 PageParams(params) 872 803 }); 873 804 ··· 876 807 // First page 877 808 assert_eq!(routes[0].props.page, 0); 878 809 assert_eq!(routes[0].props.items.len(), 2); 879 - assert_eq!(routes[0].props.items[0], "rust"); 880 - assert_eq!(routes[0].props.items[1], "javascript"); 810 + assert_eq!(routes[0].props.items[0], &"rust"); 811 + assert_eq!(routes[0].props.items[1], &"javascript"); 881 812 882 813 // Second page 883 814 assert_eq!(routes[1].props.page, 1); 884 815 assert_eq!(routes[1].props.items.len(), 2); 885 - assert_eq!(routes[1].props.items[0], "python"); 886 - assert_eq!(routes[1].props.items[1], "go"); 816 + assert_eq!(routes[1].props.items[0], &"python"); 817 + assert_eq!(routes[1].props.items[1], &"go"); 887 818 888 819 // Third page 889 820 assert_eq!(routes[2].props.page, 2); 890 821 assert_eq!(routes[2].props.items.len(), 1); 891 - assert_eq!(routes[2].props.items[0], "typescript"); 822 + assert_eq!(routes[2].props.items[0], &"typescript"); 823 + } 824 + 825 + #[test] 826 + fn test_url_optional_parameter_with_value() { 827 + let page = TestPage { 828 + route: "/articles/[slug]/[page]".to_string(), 829 + }; 830 + 831 + let mut params = FxHashMap::default(); 832 + params.insert("slug".to_string(), Some("hello-world".to_string())); 833 + params.insert("page".to_string(), Some("2".to_string())); 834 + let route_params = PageParams(params); 835 + 836 + assert_eq!(page.url(&route_params), "/articles/hello-world/2"); 837 + } 838 + 839 + #[test] 840 + fn test_url_optional_parameter_none() { 841 + let page = TestPage { 842 + route: "/articles/[slug]/[page]".to_string(), 843 + }; 844 + 845 + let mut params = FxHashMap::default(); 846 + params.insert("slug".to_string(), Some("hello-world".to_string())); 847 + params.insert("page".to_string(), None); 848 + let route_params = PageParams(params); 849 + 850 + assert_eq!(page.url(&route_params), "/articles/hello-world/"); 851 + } 852 + 853 + #[test] 854 + fn test_url_multiple_optional_parameters() { 855 + let page = TestPage { 856 + route: "/[lang]/articles/[category]/[page]".to_string(), 857 + }; 858 + 859 + let mut params = FxHashMap::default(); 860 + params.insert("lang".to_string(), None); 861 + params.insert("category".to_string(), Some("rust".to_string())); 862 + params.insert("page".to_string(), None); 863 + let route_params = PageParams(params); 864 + 865 + assert_eq!(page.url(&route_params), "/articles/rust/"); 866 + } 867 + 868 + #[test] 869 + fn test_file_path_optional_parameter_with_value() { 870 + let page = TestPage { 871 + route: "/articles/[slug]/[page]".to_string(), 872 + }; 873 + 874 + let mut params = FxHashMap::default(); 875 + params.insert("slug".to_string(), Some("hello-world".to_string())); 876 + params.insert("page".to_string(), Some("2".to_string())); 877 + let route_params = PageParams(params); 878 + 879 + let output_dir = Path::new("/dist"); 880 + let expected = Path::new("/dist/articles/hello-world/2/index.html"); 881 + 882 + assert_eq!(page.file_path(&route_params, output_dir), expected); 883 + } 884 + 885 + #[test] 886 + fn test_file_path_optional_parameter_none() { 887 + let page = TestPage { 888 + route: "/articles/[slug]/[page]".to_string(), 889 + }; 890 + 891 + let mut params = FxHashMap::default(); 892 + params.insert("slug".to_string(), Some("hello-world".to_string())); 893 + params.insert("page".to_string(), None); 894 + let route_params = PageParams(params); 895 + 896 + let output_dir = Path::new("/dist"); 897 + let expected = Path::new("/dist/articles/hello-world/index.html"); 898 + 899 + assert_eq!(page.file_path(&route_params, output_dir), expected); 900 + } 901 + 902 + #[test] 903 + fn test_file_path_optional_parameter_endpoint() { 904 + let page = TestPage { 905 + route: "/api/[version]/data.json".to_string(), 906 + }; 907 + 908 + let mut params = FxHashMap::default(); 909 + params.insert("version".to_string(), None); 910 + let route_params = PageParams(params); 911 + 912 + let output_dir = Path::new("/dist"); 913 + let expected = Path::new("/dist/api/data.json"); 914 + 915 + assert_eq!(page.file_path(&route_params, output_dir), expected); 892 916 } 893 917 }
+2 -2
crates/oubli/src/lib.rs
··· 1 - use maudit::content::ContentEntry; 1 + use maudit::content::{ContentEntry, Entry}; 2 2 use maudit::route::prelude::*; 3 3 4 4 use maudit::{ ··· 167 167 Box::new(move || { 168 168 let mut entries = Vec::new(); 169 169 for (name, stringified_ident) in names.iter() { 170 - entries.push(ContentEntry::new( 170 + entries.push(Entry::create( 171 171 stringified_ident.to_string(), 172 172 None, 173 173 None,
+7 -7
website/content/docs/content.md
··· 37 37 } 38 38 ``` 39 39 40 - Where `loader` and `glob_markdown` are functions returning a Vec of `ContentEntry`. Typically, a loader also accepts a type argument specifying the shape of the data for each entries it returns, which will be used inside your pages to provide typed content. 40 + Where `loader` and `glob_markdown` are functions returning a Vec of `Entry`. Typically, a loader also accepts a type argument specifying the shape of the data for each entries it returns, which will be used inside your pages to provide typed content. 41 41 42 42 ## Using a content source in pages 43 43 ··· 96 96 97 97 ### Custom loaders 98 98 99 - As said previously, a loader is simply a function returning a Vec of `ContentEntry`. This means you can create your own loaders to load content from any source you want, as long as you return the right type. 99 + As said previously, a loader is a function returning a Vec of `Entry`. This means you can create your own loaders to load content from any source you want, as long as you return the right type. 100 100 101 101 For instance, you could create a loader that fetches a remote JSON file and deserializes it into a struct, producing a content source with a single entry: 102 102 103 103 ```rs 104 - use maudit::content::{ContentEntry}; 104 + use maudit::content::{Entry, ContentEntry}; 105 105 106 106 #[derive(serde::Deserialize)] 107 107 pub struct MyType { ··· 109 109 pub name: String, 110 110 } 111 111 112 - pub fn my_loader(path: &str) -> Vec<ContentEntry<MyType>> { 112 + pub fn my_loader(path: &str) -> Vec<Entry<MyType>> { 113 113 let response = reqwest::blocking::get(path).unwrap(); 114 114 let data = response.json::<MyType>().unwrap(); 115 115 116 - vec![ContentEntry::new(data.id.into(), None, None, data, None)] 116 + vec![Entry::create(data.id.into(), None, None, data, None)] 117 117 } 118 118 119 119 // Use it as a content source: ··· 149 149 } 150 150 ``` 151 151 152 - Content entries can also be rendered by passing a render function to the `render` method of `ContentEntry`. 152 + Content entries can also be rendered by passing a render function to the `render` method of `Entry`. 153 153 154 154 ```rs 155 - ContentEntry::new( 155 + Entry::create( 156 156 data.id.into(), 157 157 Some(Box::new(|content, ctx| { 158 158 // render the content string into HTML
+7 -1
website/content/docs/performance.md
··· 37 37 38 38 ### Preventing build directory block 39 39 40 - As Maudit recompile your project on every change, it is possible to run into issues where the build directory is first blocked by another process, [most commonly `rust-analyzer` in your editor.](https://github.com/rust-lang/rust-analyzer/issues/4616), slowing down builds significantly. 40 + As Maudit recompile your project on every change, it is possible to run into issues where the build directory is first blocked by another process, [most commonly `rust-analyzer` in your editor](https://github.com/rust-lang/rust-analyzer/issues/4616), slowing down builds significantly. 41 + 42 + To avoid this, [you can change the build directory used by `rust-analyzer`](https://rust-analyzer.github.io/book/configuration#cargo.targetDir) to a different directory than the default `target` directory used by Cargo. For example, in VSCode you can add the following to your settings: 43 + 44 + ```json 45 + "rust-analyzer.cargo.targetDir": true // or a specific path like "target-ra" 46 + ``` 41 47 42 48 While this does improve the time it takes to get feedback on changes, note that changing `rust-analyzer` settings to use a different build directory will use a lot of disk space. 43 49
+11 -2
website/content/docs/routing.md
··· 72 72 impl Route<Params> for Post { 73 73 fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 74 74 let params = ctx.params::<Params>(); 75 - RenderResult::Text(format!("Hello, {}!", params.slug)) 75 + 76 + format!("Hello, {}!", params.slug) 76 77 } 77 78 78 79 fn pages(&self, ctx: &mut DynamicRouteContext) -> Pages<Params> { ··· 114 115 115 116 Like static routes, dynamic routes must be [registered](#registering-routes) in the `coronate` function in order for them to be built. 116 117 118 + ### Optional parameters 119 + 120 + Dynamic routes can also have optional parameters by using the `Option<T>` type in the parameters struct. These parameters will be completely removed from the URL and file path when they are `None`. For instance, in a route with the path `/posts/[category]/[slug]`, if the `category` parameter is `None`, the resulting URL will be `/posts/my-blog-post/`. 121 + 122 + This feature is notably useful when creating paginated routes (ex: `/posts/[page]`), where the first page sometimes does not include a page number in the URL, but subsequent pages do (e.g., `/blog` for the first page and `/blog/1` for the second page). 123 + 124 + Maudit will automatically collapse repeated slashes in the URL and file path into a single slash, as such `/articles/[slug]/[page]/` where `page` is `None` will result in `/articles/my-article/`, and not `/articles/my-article//`. 125 + 117 126 ## Endpoints 118 127 119 - Maudit supports returning other types of content besides HTML, such as JSON, plain text or binary data. To do this, simply add a file extension to the route path and return the content in the `render` method. Both static and dynamic routes can be used as endpoints. 128 + Maudit supports returning other types of content besides HTML, such as JSON, plain text or binary data. To do this, add a file extension to the route path and return the content in the `render` method. Both static and dynamic routes can be used as endpoints. 120 129 121 130 ```rs 122 131 use maudit::route::prelude::*;
+5 -2
website/src/content.rs
··· 1 1 use maud::{PreEscaped, Render}; 2 - use maudit::content::{glob_markdown, markdown_entry, ContentSources}; 2 + use maudit::content::{glob_markdown, markdown_entry, ContentSources, MarkdownOptions}; 3 3 use maudit::content_sources; 4 4 use serde::Deserialize; 5 5 ··· 39 39 } 40 40 41 41 pub fn content_sources() -> ContentSources { 42 - content_sources!["docs" => glob_markdown::<DocsContent>("content/docs/*.md", None), "news" => glob_markdown::<NewsContent>("content/news/*.md", None)] 42 + content_sources!["docs" => glob_markdown::<DocsContent>("content/docs/*.md", Some(MarkdownOptions { 43 + highlight_theme: "base16-eighties.dark".into(), 44 + ..Default::default() 45 + })), "news" => glob_markdown::<NewsContent>("content/news/*.md", None)] 43 46 }
+2 -2
website/src/routes/docs.rs
··· 1 1 use maud::{html, Markup, PreEscaped}; 2 - use maudit::{content::ContentEntry, route::prelude::*}; 2 + use maudit::{content::EntryInner, route::prelude::*}; 3 3 4 4 use crate::{content::DocsContent, layout::docs_layout}; 5 5 ··· 19 19 } 20 20 } 21 21 22 - fn render_entry(entry: &ContentEntry<DocsContent>, ctx: &mut PageContext) -> Markup { 22 + fn render_entry(entry: &EntryInner<DocsContent>, ctx: &mut PageContext) -> Markup { 23 23 html! { 24 24 section.mb-4.border-b."border-[#e9e9e7]".pb-2 { 25 25 @if let Some(section) = &entry.data(ctx).section {