Rust library to generate static websites
5
fork

Configure Feed

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

feat(routing): Add support for route variants to power i18n

+802 -120
+8
Cargo.lock
··· 2636 2636 ] 2637 2637 2638 2638 [[package]] 2639 + name = "maudit-example-i18n" 2640 + version = "0.1.0" 2641 + dependencies = [ 2642 + "maud", 2643 + "maudit", 2644 + ] 2645 + 2646 + [[package]] 2639 2647 name = "maudit-example-image-processing" 2640 2648 version = "0.1.0" 2641 2649 dependencies = [
+142 -7
crates/maudit-macros/src/lib.rs
··· 1 1 use proc_macro::TokenStream; 2 2 use quote::quote; 3 3 use syn::parse::{self, Parse, ParseStream, Parser as _, Result}; 4 - use syn::{Expr, ItemStruct, parse_macro_input}; 4 + use syn::{Expr, Ident, ItemStruct, Token, parse_macro_input, punctuated::Punctuated}; 5 5 6 6 struct Args { 7 - path: Expr, 7 + path: Option<Expr>, 8 8 } 9 9 10 10 impl Parse for Args { 11 11 fn parse(input: ParseStream) -> Result<Self> { 12 - let path = input.parse()?; 12 + if input.is_empty() { 13 + Ok(Args { path: None }) 14 + } else { 15 + let path = input.parse()?; 16 + Ok(Args { path: Some(path) }) 17 + } 18 + } 19 + } 20 + 21 + struct LocaleVariant { 22 + locale: Ident, 23 + path: Expr, 24 + } 13 25 14 - Ok(Args { path }) 26 + impl Parse for LocaleVariant { 27 + fn parse(input: ParseStream) -> Result<Self> { 28 + let locale = input.parse::<Ident>()?; 29 + 30 + let content; 31 + syn::parenthesized!(content in input); 32 + 33 + content.parse::<Ident>()?; // "path" 34 + content.parse::<Token![=]>()?; 35 + let path = content.parse::<Expr>()?; 36 + 37 + Ok(LocaleVariant { locale, path }) 38 + } 39 + } 40 + 41 + struct LocalesArgs { 42 + variants: Punctuated<LocaleVariant, Token![,]>, 43 + } 44 + 45 + impl Parse for LocalesArgs { 46 + fn parse(input: ParseStream) -> Result<Self> { 47 + let variants = Punctuated::parse_terminated(input)?; 48 + Ok(LocalesArgs { variants }) 15 49 } 16 50 } 17 51 18 52 #[proc_macro_attribute] 53 + pub fn locales(attrs: TokenStream, item: TokenStream) -> TokenStream { 54 + // Parse and validate the locales 55 + let locales_args = syn::parse_macro_input!(attrs as LocalesArgs); 56 + let item_struct = syn::parse_macro_input!(item as ItemStruct); 57 + 58 + // Serialize the locale data into a doc comment that route macro can parse 59 + let mut locale_data = String::from("maudit_locales:"); 60 + for variant in &locales_args.variants { 61 + let locale_name = variant.locale.to_string(); 62 + let locale_path = match &variant.path { 63 + Expr::Lit(lit) => { 64 + if let syn::Lit::Str(s) = &lit.lit { 65 + s.value() 66 + } else { 67 + panic!("locale path must be a string literal"); 68 + } 69 + } 70 + _ => panic!("locale path must be a string literal"), 71 + }; 72 + locale_data.push_str(&format!("{}={},", locale_name, locale_path)); 73 + } 74 + 75 + // Add the doc comment to the struct's attributes 76 + let mut modified_struct = item_struct.clone(); 77 + modified_struct.attrs.push(syn::parse_quote! { 78 + #[doc = #locale_data] 79 + }); 80 + 81 + let expanded = quote! { 82 + #modified_struct 83 + }; 84 + 85 + TokenStream::from(expanded) 86 + } 87 + 88 + #[proc_macro_attribute] 19 89 pub fn route(attrs: TokenStream, item: TokenStream) -> TokenStream { 20 90 // Parse the input tokens into a syntax tree 21 91 let item_struct = syn::parse_macro_input!(item as ItemStruct); 22 92 let attrs = syn::parse_macro_input!(attrs as Args); 23 93 24 94 let struct_name = &item_struct.ident; 25 - let path = &attrs.path; 95 + 96 + // Look for locale data in doc comments (set by locales macro) 97 + let locale_data = item_struct.attrs.iter().find_map(|attr| { 98 + if attr.path().is_ident("doc") 99 + && let syn::Meta::NameValue(meta) = &attr.meta 100 + && let Expr::Lit(lit) = &meta.value 101 + && let syn::Lit::Str(s) = &lit.lit 102 + { 103 + let content = s.value(); 104 + if content.starts_with("maudit_locales:") { 105 + return Some(content); 106 + } 107 + } 108 + None 109 + }); 110 + 111 + let variant_methods = if let Some(locale_data) = locale_data { 112 + // Parse the locale data from the doc comment 113 + let data = locale_data.strip_prefix("maudit_locales:").unwrap(); 114 + let mut variants = Vec::new(); 26 115 27 - let expanded = quote! { 28 - impl maudit::route::InternalRoute for #struct_name { 116 + for pair in data.split(',') { 117 + if pair.is_empty() { 118 + continue; 119 + } 120 + let parts: Vec<&str> = pair.split('=').collect(); 121 + if parts.len() == 2 { 122 + let id = parts[0].to_string(); 123 + let path = parts[1].to_string(); 124 + variants.push((id, path)); 125 + } 126 + } 127 + 128 + let variant_tuples = variants.iter().map(|(id, path)| { 129 + quote! { 130 + (#id.to_string(), #path.to_string()) 131 + } 132 + }); 133 + 134 + quote! { 135 + fn variants(&self) -> Vec<(String, String)> { 136 + vec![#(#variant_tuples),*] 137 + } 138 + } 139 + } else { 140 + quote! { 141 + fn variants(&self) -> Vec<(String, String)> { 142 + vec![] 143 + } 144 + } 145 + }; 146 + 147 + // Generate route_raw implementation based on whether path is provided 148 + let route_raw_impl = if let Some(path) = &attrs.path { 149 + quote! { 29 150 fn route_raw(&self) -> String { 30 151 #path.to_string() 31 152 } 153 + } 154 + } else { 155 + quote! { 156 + fn route_raw(&self) -> String { 157 + String::new() 158 + } 159 + } 160 + }; 161 + 162 + let expanded = quote! { 163 + impl maudit::route::InternalRoute for #struct_name { 164 + #route_raw_impl 165 + 166 + #variant_methods 32 167 } 33 168 34 169 impl maudit::route::FullRoute for #struct_name {
+173 -33
crates/maudit/src/build.rs
··· 17 17 logging::print_title, 18 18 route::{ 19 19 CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams, 20 - RouteType, 20 + build_file_path_with_params, build_url_with_params, 21 21 }, 22 + routing::{extract_params_from_raw_route, guess_if_route_is_endpoint}, 22 23 }; 23 24 use colored::{ColoredString, Colorize}; 24 25 use log::{debug, info, trace, warn}; ··· 130 131 // If you manage to make it parallel and it actually improves performance, please open a PR! 131 132 for route in routes { 132 133 let cached_route = CachedRoute::new(*route); 134 + let base_path = route.route_raw(); 135 + let variants = cached_route.variants(); 136 + 137 + trace!(target: "build", "Processing route: base='{}', variants={}", base_path, variants.len()); 138 + 139 + // Determine if we need to fetch dynamic pages 140 + let base_params = extract_params_from_raw_route(&base_path); 133 141 134 - match cached_route.route_type() { 135 - RouteType::Static => { 136 - let route_start = Instant::now(); 142 + // Collect logging data for structured output 143 + let mut base_log_line = String::new(); 144 + let mut variant_logs: Vec<String> = Vec::new(); 145 + let mut base_dynamic_logs: Vec<String> = Vec::new(); 137 146 147 + // Generate base route pages 148 + if !base_path.is_empty() { 149 + if base_params.is_empty() { 150 + // Static base route - generate one page 138 151 let mut route_assets = 139 152 RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 140 - 141 153 let params = PageParams::default(); 142 154 let url = cached_route.url(&params); 143 155 ··· 146 158 &mut route_assets, 147 159 &url, 148 160 &options.base_url, 161 + None, 149 162 ))?; 150 163 151 164 let file_path = cached_route.file_path(&params, &options.output_dir); 152 - 153 165 write_route_file(&result, &file_path)?; 154 166 155 - info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 167 + base_log_line = format!("{} -> {}", url, file_path.to_string_lossy().dimmed()); 156 168 157 169 build_pages_images.extend(route_assets.images); 158 170 build_pages_scripts.extend(route_assets.scripts); 159 171 build_pages_styles.extend(route_assets.styles); 160 172 161 173 build_metadata.add_page( 162 - route.route_raw().to_string(), 174 + base_path.clone(), 163 175 file_path.to_string_lossy().to_string(), 164 176 None, 165 177 ); 166 178 167 179 page_count += 1; 168 - } 169 - RouteType::Dynamic => { 180 + } else { 181 + // Dynamic base route - just show the pattern 182 + base_log_line = base_path.clone(); 183 + 184 + // Fetch pages for base route with no variant 170 185 let mut page_assets = 171 186 RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 172 - 173 187 let pages = route.get_pages(&mut DynamicRouteContext { 174 188 content: content_sources, 175 189 assets: &mut page_assets, 190 + variant: None, 176 191 }); 177 192 178 193 if pages.is_empty() { 179 - warn!(target: "build", "{} is a dynamic route, but its implementation of Route::pages returned an empty Vec. No pages will be generated for this route.", route.route_raw().to_string().bold()); 180 - continue; 194 + warn!(target: "build", "{} has dynamic parameters but Route::pages returned an empty Vec. No pages will be generated.", base_path.bold()); 181 195 } else { 182 - info!(target: "build", "{}", route.route_raw().to_string().bold()); 196 + // Generate pages for each dynamic page (logging collected later) 197 + for page in pages { 198 + let url = cached_route.url(&page.0); 199 + 200 + let content = route.build(&mut PageContext::from_dynamic_route( 201 + &page, 202 + content_sources, 203 + &mut page_assets, 204 + &url, 205 + &options.base_url, 206 + None, 207 + ))?; 208 + 209 + let file_path = cached_route.file_path(&page.0, &options.output_dir); 210 + write_route_file(&content, &file_path)?; 211 + 212 + base_dynamic_logs.push(format!("{}", file_path.to_string_lossy().dimmed())); 213 + 214 + build_metadata.add_page( 215 + base_path.clone(), 216 + file_path.to_string_lossy().to_string(), 217 + Some(page.0.0.clone()), 218 + ); 219 + 220 + page_count += 1; 221 + } 183 222 } 184 223 185 - for page in pages { 186 - let route_start = Instant::now(); 224 + build_pages_images.extend(page_assets.images); 225 + build_pages_scripts.extend(page_assets.scripts); 226 + build_pages_styles.extend(page_assets.styles); 227 + } 228 + } else if !variants.is_empty() { 229 + // No base route, just variants 230 + base_log_line = "(variants only)".to_string(); 231 + } 187 232 188 - let url = cached_route.url(&page.0); 233 + // Generate variant pages 234 + for (variant_id, variant_path) in variants { 235 + let variant_params = extract_params_from_raw_route(&variant_path); 189 236 190 - let content = route.build(&mut PageContext::from_dynamic_route( 191 - &page, 192 - content_sources, 193 - &mut page_assets, 194 - &url, 195 - &options.base_url, 196 - ))?; 237 + if variant_params.is_empty() { 238 + // Static variant - generate one page 239 + let mut route_assets = 240 + RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 197 241 198 - let file_path = cached_route.file_path(&page.0, &options.output_dir); 242 + let url = if variant_path.starts_with('/') { 243 + variant_path.clone() 244 + } else { 245 + format!("/{}", variant_path) 246 + }; 199 247 200 - write_route_file(&content, &file_path)?; 248 + let result = route.build(&mut PageContext::from_static_route( 249 + content_sources, 250 + &mut route_assets, 251 + &url, 252 + &options.base_url, 253 + Some(variant_id.clone()), 254 + ))?; 255 + 256 + let file_path = options 257 + .output_dir 258 + .join(variant_path.trim_start_matches('/')) 259 + .join("index.html"); 260 + 261 + write_route_file(&result, &file_path)?; 262 + 263 + variant_logs.push(format!( 264 + "├─ {} -> {}", 265 + url, 266 + file_path.to_string_lossy().dimmed() 267 + )); 268 + 269 + build_pages_images.extend(route_assets.images); 270 + build_pages_scripts.extend(route_assets.scripts); 271 + build_pages_styles.extend(route_assets.styles); 272 + 273 + build_metadata.add_page( 274 + format!("{} ({})", base_path, variant_id), 275 + file_path.to_string_lossy().to_string(), 276 + None, 277 + ); 278 + 279 + page_count += 1; 280 + } else { 281 + // Dynamic variant - show pattern then pages 282 + let variant_log = format!("├─ {}", variant_path); 283 + let mut variant_page_logs: Vec<String> = Vec::new(); 284 + 285 + // Fetch pages for this variant 286 + let mut page_assets = 287 + RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 288 + let pages = route.get_pages(&mut DynamicRouteContext { 289 + content: content_sources, 290 + assets: &mut page_assets, 291 + variant: Some(&variant_id), 292 + }); 293 + 294 + if pages.is_empty() { 295 + warn!(target: "build", "Variant {} has dynamic parameters but Route::pages returned an empty Vec.", variant_id.bold()); 296 + } else { 297 + for page in pages { 298 + let url = build_url_with_params( 299 + &variant_path, 300 + &variant_params, 301 + &page.0, 302 + guess_if_route_is_endpoint(&variant_path), 303 + ); 201 304 202 - info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 305 + let content = route.build(&mut PageContext::from_dynamic_route( 306 + &page, 307 + content_sources, 308 + &mut page_assets, 309 + &url, 310 + &options.base_url, 311 + Some(variant_id.clone()), 312 + ))?; 203 313 204 - build_metadata.add_page( 205 - route.route_raw().to_string(), 206 - file_path.to_string_lossy().to_string(), 207 - Some(page.0.0), 208 - ); 314 + let file_path = build_file_path_with_params( 315 + &variant_path, 316 + &variant_params, 317 + &page.0, 318 + &options.output_dir, 319 + guess_if_route_is_endpoint(&variant_path), 320 + ); 321 + 322 + write_route_file(&content, &file_path)?; 323 + 324 + variant_page_logs 325 + .push(format!("│ ├─ {}", file_path.to_string_lossy().dimmed())); 326 + 327 + build_metadata.add_page( 328 + format!("{} ({})", base_path, variant_id), 329 + file_path.to_string_lossy().to_string(), 330 + Some(page.0.0.clone()), 331 + ); 209 332 210 - page_count += 1; 333 + page_count += 1; 334 + } 211 335 } 212 336 337 + // Add variant pattern line 338 + variant_logs.push(variant_log); 339 + // Add all the variant's pages 340 + variant_logs.extend(variant_page_logs); 341 + 213 342 build_pages_images.extend(page_assets.images); 214 343 build_pages_scripts.extend(page_assets.scripts); 215 344 build_pages_styles.extend(page_assets.styles); 216 345 } 346 + } 347 + 348 + // Output logging in hierarchical structure 349 + if !base_log_line.is_empty() { 350 + info!(target: "pages", "{}", base_log_line); 351 + } 352 + for variant_log in variant_logs { 353 + info!(target: "pages", "{}", variant_log); 354 + } 355 + for base_page_log in base_dynamic_logs { 356 + info!(target: "pages", "├─ {}", base_page_log); 217 357 } 218 358 } 219 359
+1
crates/maudit/src/content/markdown/shortcodes_tests.rs
··· 75 75 params: &(), 76 76 props: &(), 77 77 base_url: &None, 78 + variant: None, 78 79 }; 79 80 80 81 f(&mut ctx)
+54 -9
crates/maudit/src/route.rs
··· 4 4 use crate::assets::{Asset, RouteAssets}; 5 5 use crate::content::{ContentSources, Entry}; 6 6 use crate::errors::BuildError; 7 - use crate::routing::{ 8 - extract_params_from_raw_route, get_route_type_from_route_params, guess_if_route_is_endpoint, 9 - }; 7 + use crate::routing::{extract_params_from_raw_route, guess_if_route_is_endpoint}; 10 8 use rustc_hash::FxHashMap; 11 9 use std::any::Any; 12 10 use std::path::{Path, PathBuf}; ··· 275 273 pub current_path: &'a String, 276 274 /// The base URL as defined in [`BuildOptions::base_url`](crate::BuildOptions::base_url) 277 275 pub base_url: &'a Option<String>, 276 + /// The variant being rendered, e.g. `Some("en")` for English variant, `None` for base route 277 + pub variant: Option<String>, 278 278 } 279 279 280 280 impl<'a> PageContext<'a> { ··· 283 283 assets: &'a mut RouteAssets, 284 284 current_path: &'a String, 285 285 base_url: &'a Option<String>, 286 + variant: Option<String>, 286 287 ) -> Self { 287 - Self { 288 + PageContext { 288 289 params: &(), 289 290 props: &(), 290 291 content, 291 292 assets, 292 293 current_path, 293 294 base_url, 295 + variant, 294 296 } 295 297 } 296 298 ··· 300 302 assets: &'a mut RouteAssets, 301 303 current_path: &'a String, 302 304 base_url: &'a Option<String>, 305 + variant: Option<String>, 303 306 ) -> Self { 304 307 Self { 305 308 params: dynamic_page.1.as_ref(), ··· 308 311 assets, 309 312 current_path, 310 313 base_url, 314 + variant, 311 315 } 312 316 } 313 317 ··· 383 387 /// } 384 388 /// } 385 389 /// ``` 390 + /// Allows to access content and assets in a dynamic route's pages method. 386 391 pub struct DynamicRouteContext<'a> { 387 392 pub content: &'a ContentSources, 388 393 pub assets: &'a mut RouteAssets, 394 + /// The variant being generated, e.g. `Some("en")` for English variant, `None` for base route 395 + pub variant: Option<&'a str>, 389 396 } 390 397 391 398 /// Must be implemented for every page of your website. ··· 464 471 /// We expose it because the derive macro implements it for the user behind the scenes. 465 472 pub trait InternalRoute { 466 473 fn route_raw(&self) -> String; 474 + 475 + fn variants(&self) -> Vec<(String, String)> { 476 + vec![] 477 + } 478 + 467 479 fn is_endpoint(&self) -> bool { 468 480 guess_if_route_is_endpoint(&self.route_raw()) 469 481 } 470 482 fn route_type(&self) -> RouteType { 471 483 let params_def = extract_params_from_raw_route(&self.route_raw()); 472 484 473 - get_route_type_from_route_params(&params_def) 485 + // Check if base route is dynamic 486 + if !params_def.is_empty() { 487 + return RouteType::Dynamic; 488 + } 489 + 490 + // Check if any variant is dynamic 491 + let variants = self.variants(); 492 + for (_id, variant_path) in variants { 493 + let variant_params = extract_params_from_raw_route(&variant_path); 494 + if !variant_params.is_empty() { 495 + return RouteType::Dynamic; 496 + } 497 + } 498 + 499 + RouteType::Static 474 500 } 475 501 476 502 fn url(&self, params: &PageParams) -> String { ··· 541 567 542 568 // This function and the one below are extremely performance-sensitive, as they are called for every single page during the build. 543 569 // It'd be great to optimize them as much as possible, make them allocation-free, etc. But, I'm not smart enough right now to do that! 544 - fn build_url_with_params( 570 + pub fn build_url_with_params( 545 571 route_template: &str, 546 572 params_def: &[ParameterDef], 547 573 params: &PageParams, ··· 586 612 result 587 613 } 588 614 589 - fn build_file_path_with_params( 615 + pub fn build_file_path_with_params( 590 616 route_template: &str, 591 617 params_def: &[ParameterDef], 592 618 params: &PageParams, ··· 663 689 self.inner.route_raw() 664 690 } 665 691 692 + fn variants(&self) -> Vec<(String, String)> { 693 + self.inner.variants() 694 + } 695 + 666 696 fn route_type(&self) -> RouteType { 667 - get_route_type_from_route_params(self.get_cached_params()) 697 + // Check if base route is dynamic 698 + let params_def = self.get_cached_params(); 699 + if !params_def.is_empty() { 700 + return RouteType::Dynamic; 701 + } 702 + 703 + // Check if any variant is dynamic 704 + let variants = self.variants(); 705 + for (_id, variant_path) in variants { 706 + let variant_params = extract_params_from_raw_route(&variant_path); 707 + if !variant_params.is_empty() { 708 + return RouteType::Dynamic; 709 + } 710 + } 711 + 712 + RouteType::Static 668 713 } 669 714 670 715 fn url(&self, params: &PageParams) -> String { ··· 771 816 StyleOptions, 772 817 }; 773 818 pub use crate::content::{ContentContext, ContentEntry, Entry, EntryInner, MarkdownContent}; 774 - pub use maudit_macros::{Params, route}; 819 + pub use maudit_macros::{Params, locales, route}; 775 820 } 776 821 777 822 #[cfg(test)]
+1 -64
crates/maudit/src/routing.rs
··· 1 1 use std::path::Path; 2 2 3 - use crate::route::RouteType; 4 - 5 3 #[derive(Debug, PartialEq, Clone)] 6 4 pub struct ParameterDef { 7 5 pub(crate) key: String, ··· 50 48 params 51 49 } 52 50 53 - pub fn get_route_type_from_route_params(params_def: &[ParameterDef]) -> RouteType { 54 - if params_def.is_empty() { 55 - RouteType::Static 56 - } else { 57 - RouteType::Dynamic 58 - } 59 - } 60 - 61 51 pub fn guess_if_route_is_endpoint(raw_route: &str) -> bool { 62 52 let real_path = Path::new(&raw_route); 63 53 ··· 66 56 67 57 #[cfg(test)] 68 58 mod tests { 69 - use crate::{ 70 - route::RouteType, 71 - routing::{ParameterDef, extract_params_from_raw_route, get_route_type_from_route_params}, 72 - }; 59 + use crate::routing::{ParameterDef, extract_params_from_raw_route}; 73 60 74 61 #[test] 75 62 fn test_extract_params() { ··· 136 123 }]; 137 124 138 125 assert_eq!(extract_params_from_raw_route(input), expected); 139 - } 140 - 141 - #[test] 142 - fn test_route_type_static() { 143 - let input = "/articles"; 144 - let params_def = extract_params_from_raw_route(input); 145 - assert_eq!( 146 - get_route_type_from_route_params(&params_def), 147 - RouteType::Static 148 - ); 149 - } 150 - 151 - #[test] 152 - fn test_route_type_dynamic() { 153 - let input = "/articles/[article]"; 154 - let params_def = extract_params_from_raw_route(input); 155 - assert_eq!( 156 - get_route_type_from_route_params(&params_def), 157 - RouteType::Dynamic 158 - ); 159 - } 160 - 161 - #[test] 162 - fn test_route_type_dynamic_multiple() { 163 - let input = "/articles/[article]/[id]"; 164 - let params_def = extract_params_from_raw_route(input); 165 - assert_eq!( 166 - get_route_type_from_route_params(&params_def), 167 - RouteType::Dynamic 168 - ); 169 - } 170 - 171 - #[test] 172 - fn test_route_type_dynamic_escaped() { 173 - let input = "/articles/\\[article\\]"; 174 - let params_def = extract_params_from_raw_route(input); 175 - assert_eq!( 176 - get_route_type_from_route_params(&params_def), 177 - RouteType::Static 178 - ); 179 - } 180 - 181 - #[test] 182 - fn test_route_type_dynamic_mixed_escaped_brackets() { 183 - let input = "/articles/\\[article\\]/[id]"; 184 - let params_def = extract_params_from_raw_route(input); 185 - assert_eq!( 186 - get_route_type_from_route_params(&params_def), 187 - RouteType::Dynamic 188 - ); 189 126 } 190 127 }
+4
examples/i18n/.gitignore
··· 1 + target 2 + dist 3 + node_modules 4 + .DS_Store
+12
examples/i18n/Cargo.toml
··· 1 + [package] 2 + name = "maudit-example-i18n" 3 + version = "0.1.0" 4 + edition = "2024" 5 + publish = false 6 + 7 + [package.metadata.maudit] 8 + intended_version = "0.6.4" 9 + 10 + [dependencies] 11 + maudit = { workspace = true } 12 + maud = "0.27.0"
+214
examples/i18n/README.md
··· 1 + # i18n Example 2 + 3 + This example demonstrates Maudit's **route variants** system using the `#[locales()]` macro for internationalization. 4 + 5 + ## Route Variants 6 + 7 + Route variants allow a single route to have multiple versions with different paths. The `#[locales()]` macro is a convenient preset for defining locale-based variants. 8 + 9 + ## Examples 10 + 11 + ### Variant-Only Route 12 + 13 + A route can exist _only_ as variants with no base path: 14 + 15 + ```rust 16 + #[locales(en(path = "/en"), sv(path = "/sv"), de(path = "/de"))] 17 + #[route] 18 + pub struct Index; 19 + ``` 20 + 21 + This route has no base path - it only exists through its variants at `/en`, `/sv`, and `/de`. 22 + 23 + ### Route with Base Path and Variants 24 + 25 + A route can have both a base path and localized variants: 26 + 27 + ```rust 28 + #[locales( 29 + en(path = "/en/about"), 30 + sv(path = "/sv/om-oss"), 31 + de(path = "/de/uber-uns") 32 + )] 33 + #[route("/about")] 34 + pub struct About; 35 + ``` 36 + 37 + This route is accessible at: 38 + - `/about` - the base/default path 39 + - `/en/about` - English variant 40 + - `/sv/om-oss` - Swedish variant (using natural Swedish URL structure) 41 + - `/de/uber-uns` - German variant (using natural German URL structure) 42 + 43 + ### Static Base with Dynamic Variants 44 + 45 + **Yes, it's possible!** A route can have no base path (or a static base path) while having dynamic variants: 46 + 47 + ```rust 48 + #[derive(Params, Clone)] 49 + pub struct MixedParams { 50 + pub id: String, 51 + } 52 + 53 + // No base path, but dynamic variants 54 + #[locales(en(path = "/en/products/[id]"), sv(path = "/sv/produkter/[id]"))] 55 + #[route] 56 + pub struct Mixed; 57 + 58 + impl Route<MixedParams> for Mixed { 59 + fn pages(&self, _ctx: &mut DynamicRouteContext) -> Pages<MixedParams> { 60 + vec![ 61 + Page::from_params(MixedParams { id: "laptop".to_string() }), 62 + Page::from_params(MixedParams { id: "phone".to_string() }), 63 + ] 64 + } 65 + 66 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 67 + let params = ctx.params::<MixedParams>(); 68 + // Render using params.id 69 + } 70 + } 71 + ``` 72 + 73 + This generates only variant pages: 74 + - `/en/products/laptop` and `/en/products/phone` 75 + - `/sv/produkter/laptop` and `/sv/produkter/phone` 76 + 77 + The build system automatically detects that variants have dynamic parameters and generates all dynamic pages for each variant. 78 + 79 + ### Dynamic Routes with Variants 80 + 81 + Dynamic routes can also have variants! Each variant will generate all the dynamic pages: 82 + 83 + ```rust 84 + #[derive(Params, Clone)] 85 + pub struct ArticleParams { 86 + pub slug: String, 87 + } 88 + 89 + #[locales(en(path = "/en/articles/[slug]"), sv(path = "/sv/artiklar/[slug]"))] 90 + #[route("/articles/[slug]")] 91 + pub struct Article; 92 + 93 + impl Route<ArticleParams> for Article { 94 + fn pages(&self, _ctx: &mut DynamicRouteContext) -> Pages<ArticleParams> { 95 + vec![ 96 + Page::from_params(ArticleParams { slug: "hello-world".to_string() }), 97 + Page::from_params(ArticleParams { slug: "getting-started".to_string() }), 98 + ] 99 + } 100 + 101 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 102 + let params = ctx.params::<ArticleParams>(); 103 + // Render using params.slug 104 + } 105 + } 106 + ``` 107 + 108 + This generates: 109 + - `/articles/hello-world` and `/articles/getting-started` - base pages 110 + - `/en/articles/hello-world` and `/en/articles/getting-started` - English variants 111 + - `/sv/artiklar/hello-world` and `/sv/artiklar/getting-started` - Swedish variants (note the localized "artiklar" path segment) 112 + 113 + ## API 114 + 115 + ### Variant Metadata 116 + 117 + Routes with variants expose a method via the `InternalRoute` trait: 118 + 119 + - `variants(&self) -> Vec<(String, String)>` - Get all variants as `(id, path)` tuples 120 + 121 + Example: 122 + ```rust 123 + let about = About; 124 + let variants = about.variants(); 125 + // Returns: vec![ 126 + // ("en".to_string(), "/en/about".to_string()), 127 + // ("sv".to_string(), "/sv/om-oss".to_string()), 128 + // ("de".to_string(), "/de/uber-uns".to_string()), 129 + // ] 130 + ``` 131 + 132 + ### Variant Context 133 + 134 + Both `DynamicRouteContext` and `PageContext` include variant information: 135 + 136 + ```rust 137 + pub struct DynamicRouteContext<'a> { 138 + pub content: &'a mut ContentSources, 139 + pub assets: &'a mut RouteAssets, 140 + pub variant: Option<&'a str>, // None for base route, Some("en") for variants 141 + } 142 + 143 + pub struct PageContext<'a> { 144 + // ... other fields 145 + pub variant: Option<String>, // None for base route, Some("en") for variants 146 + } 147 + ``` 148 + 149 + **Important:** `pages()` is called separately for each variant, so you can return different pages per variant: 150 + 151 + ```rust 152 + impl Route<ArticleParams> for Article { 153 + fn pages(&self, ctx: &mut DynamicRouteContext) -> Pages<ArticleParams> { 154 + match ctx.variant { 155 + Some("en") => { 156 + // Return English-only articles 157 + vec![Page::from_params(ArticleParams { slug: "hello".to_string() })] 158 + } 159 + Some("sv") => { 160 + // Return Swedish-only articles 161 + vec![Page::from_params(ArticleParams { slug: "hej".to_string() })] 162 + } 163 + None => { 164 + // Return all articles for base route 165 + vec![ 166 + Page::from_params(ArticleParams { slug: "hello".to_string() }), 167 + Page::from_params(ArticleParams { slug: "hej".to_string() }), 168 + ] 169 + } 170 + _ => vec![] 171 + } 172 + } 173 + 174 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 175 + let params = ctx.params::<ArticleParams>(); 176 + 177 + // Render differently based on variant 178 + let greeting = match ctx.variant.as_deref() { 179 + Some("en") => "Hello", 180 + Some("sv") => "Hej", 181 + None => "Hi", 182 + _ => "?" 183 + }; 184 + 185 + format!("{}: {}", greeting, params.slug) 186 + } 187 + } 188 + ``` 189 + 190 + ## How It Works 191 + 192 + When you run the build, Maudit automatically generates pages for all defined variants: 193 + 194 + ``` 195 + $ cargo run 196 + generating pages 197 + 16:21:41 pages /en -> dist/en/index.html (+169μs) 198 + 16:21:41 pages /sv -> dist/sv/index.html (+51μs) 199 + 16:21:41 pages /de -> dist/de/index.html (+42μs) 200 + 16:21:41 pages /about -> dist/about/index.html (+56μs) 201 + 16:21:41 pages /en/about -> dist/en/about/index.html (+59μs) 202 + 16:21:41 pages /sv/om-oss -> dist/sv/om-oss/index.html (+54μs) 203 + 16:21:41 pages /de/uber-uns -> dist/de/uber-uns/index.html (+44μs) 204 + 16:38:08 build /articles/[slug] 205 + 16:38:08 pages ├─ dist/articles/hello-world/index.html (+90μs) 206 + 16:38:08 pages ├─ dist/articles/getting-started/index.html (+56μs) 207 + 16:38:08 pages ├─ dist/en/articles/hello-world/index.html (+85μs) 208 + 16:38:08 pages ├─ dist/en/articles/getting-started/index.html (+53μs) 209 + 16:38:08 pages ├─ dist/sv/artiklar/hello-world/index.html (+65μs) 210 + 16:38:08 pages ├─ dist/sv/artiklar/getting-started/index.html (+48μs) 211 + 16:38:08 pages generated 13 pages in 1ms 212 + ``` 213 + 214 + Each variant is treated as a separate page with its own URL and output file. For dynamic routes, all pages are generated for each variant automatically.
+17
examples/i18n/src/layout.rs
··· 1 + use maud::{DOCTYPE, Markup, html}; 2 + 3 + pub fn layout(content: Markup) -> Markup { 4 + html! { 5 + (DOCTYPE) 6 + html lang="en" { 7 + head { 8 + meta charset="utf-8"; 9 + meta name="viewport" content="width=device-width, initial-scale=1"; 10 + title { "i18n Example" } 11 + } 12 + body { 13 + (content) 14 + } 15 + } 16 + } 17 + }
+13
examples/i18n/src/main.rs
··· 1 + mod layout; 2 + mod routes; 3 + 4 + use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 5 + use routes::{About, Article, Index, Mixed}; 6 + 7 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 8 + coronate( 9 + routes![Index, About, Article, Mixed], 10 + content_sources![], 11 + BuildOptions::default(), 12 + ) 13 + }
+28
examples/i18n/src/routes/about.rs
··· 1 + use crate::layout::layout; 2 + use maud::html; 3 + use maudit::route::prelude::*; 4 + 5 + #[locales( 6 + en(path = "/en/about"), 7 + sv(path = "/sv/om-oss"), 8 + de(path = "/de/uber-uns") 9 + )] 10 + #[route("/about")] 11 + pub struct About; 12 + 13 + impl Route for About { 14 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 15 + layout(html! { 16 + h1 { "About" } 17 + p { "This route has both a base path and localized variants." } 18 + nav { 19 + ul { 20 + li { a href="/about" { "Default" } } 21 + li { a href="/en/about" { "English" } } 22 + li { a href="/sv/om-oss" { "Swedish" } } 23 + li { a href="/de/uber-uns" { "German" } } 24 + } 25 + } 26 + }) 27 + } 28 + }
+48
examples/i18n/src/routes/articles.rs
··· 1 + use crate::layout::layout; 2 + use maud::html; 3 + use maudit::route::prelude::*; 4 + 5 + #[derive(Params, Clone)] 6 + pub struct ArticleParams { 7 + pub slug: String, 8 + } 9 + 10 + #[locales(en(path = "/en/articles/[slug]"), sv(path = "/sv/artiklar/[slug]"))] 11 + #[route("/articles/[slug]")] 12 + pub struct Article; 13 + 14 + impl Route<ArticleParams> for Article { 15 + fn pages(&self, _ctx: &mut DynamicRouteContext) -> Pages<ArticleParams> { 16 + vec![ 17 + Page::from_params(ArticleParams { 18 + slug: "hello-world".to_string(), 19 + }), 20 + Page::from_params(ArticleParams { 21 + slug: "getting-started".to_string(), 22 + }), 23 + ] 24 + } 25 + 26 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 27 + let params = ctx.params::<ArticleParams>(); 28 + 29 + let variant_info = if let Some(variant) = &ctx.variant { 30 + format!("Variant: {}", variant) 31 + } else { 32 + "Base route (no variant)".to_string() 33 + }; 34 + 35 + layout(html! { 36 + h1 { "Article: " (params.slug) } 37 + p { (variant_info) } 38 + p { "This is a dynamic route with localized variants." } 39 + nav { 40 + ul { 41 + li { a href="/articles/hello-world" { "Default" } } 42 + li { a href="/en/articles/hello-world" { "English" } } 43 + li { a href="/sv/artiklar/hello-world" { "Swedish" } } 44 + } 45 + } 46 + }) 47 + } 48 + }
+23
examples/i18n/src/routes/index.rs
··· 1 + use crate::layout::layout; 2 + use maud::html; 3 + use maudit::route::prelude::*; 4 + 5 + #[locales(en(path = "/en"), sv(path = "/sv"), de(path = "/de"))] 6 + #[route] 7 + pub struct Index; 8 + 9 + impl Route for Index { 10 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 11 + layout(html! { 12 + h1 { "i18n Example" } 13 + p { "This route only exists as variants - no base path!" } 14 + nav { 15 + ul { 16 + li { a href="/en" { "English" } } 17 + li { a href="/sv" { "Swedish" } } 18 + li { a href="/de" { "German" } } 19 + } 20 + } 21 + }) 22 + } 23 + }
+44
examples/i18n/src/routes/mixed.rs
··· 1 + use crate::layout::layout; 2 + use maud::html; 3 + use maudit::route::prelude::*; 4 + 5 + #[derive(Params, Clone)] 6 + pub struct MixedParams { 7 + pub id: String, 8 + } 9 + 10 + // Base route is static (/products) 11 + // But variants have dynamic parameters (/en/products/[id]) 12 + #[locales(en(path = "/en/products/[id]"), sv(path = "/sv/produkter/[id]"))] 13 + #[route] 14 + pub struct Mixed; 15 + 16 + impl Route<MixedParams> for Mixed { 17 + fn pages(&self, _ctx: &mut DynamicRouteContext) -> Pages<MixedParams> { 18 + vec![ 19 + Page::from_params(MixedParams { 20 + id: "laptop".to_string(), 21 + }), 22 + Page::from_params(MixedParams { 23 + id: "phone".to_string(), 24 + }), 25 + ] 26 + } 27 + 28 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 29 + let params = ctx.params::<MixedParams>(); 30 + 31 + layout(html! { 32 + h1 { "Product: " (params.id) } 33 + p { "This route has a static base path but dynamic variants!" } 34 + nav { 35 + ul { 36 + li { a href="/en/products/laptop" { "English - Laptop" } } 37 + li { a href="/en/products/phone" { "English - Phone" } } 38 + li { a href="/sv/produkter/laptop" { "Swedish - Laptop" } } 39 + li { a href="/sv/produkter/phone" { "Swedish - Phone" } } 40 + } 41 + } 42 + }) 43 + } 44 + }
+9
examples/i18n/src/routes/mod.rs
··· 1 + mod about; 2 + mod articles; 3 + mod index; 4 + mod mixed; 5 + 6 + pub use about::About; 7 + pub use articles::Article; 8 + pub use index::Index; 9 + pub use mixed::Mixed;
+11 -7
examples/library/src/build.rs
··· 39 39 &mut page_assets, 40 40 &url, 41 41 &options.base_url, 42 + None, 42 43 ); 43 44 44 45 let content = route.build(&mut ctx)?; ··· 68 69 let mut dynamic_ctx = DynamicRouteContext { 69 70 content: &content_sources, 70 71 assets: &mut page_assets, 72 + variant: None, 71 73 }; 72 74 73 75 let routes = route.get_pages(&mut dynamic_ctx); ··· 78 80 79 81 // Here the context is created from a dynamic route, as the context has to include the page parameters and properties. 80 82 let url = route.url(params); 81 - let mut ctx = PageContext::from_dynamic_route( 82 - &page, 83 - &content_sources, 84 - &mut page_assets, 85 - &url, 86 - &options.base_url, 87 - ); 83 + let mut ctx = PageContext { 84 + params: page.1.as_ref(), 85 + props: page.2.as_ref(), 86 + content: &content_sources, 87 + assets: &mut page_assets, 88 + current_path: &url, 89 + base_url: &options.base_url, 90 + variant: None, 91 + }; 88 92 89 93 // Everything below here is the same as for static routes. 90 94