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 (#77)

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

* fix: use a cleaner pattern for locales

* fix: cleanup macro

* fix: some protgress

* fix: rework build

* fix: logging

* fix: whatever I guess

* fix: logs

* fix: misc

* fix: cleanup and docs

* fix: update library example

* chore: changeset

authored by

Erika and committed by
GitHub
f40ccc75 ec60b20e

+1137 -236
+5
.sampo/changesets/pompous-prince-sampsa.md
··· 1 + --- 2 + cargo/maudit: minor 3 + --- 4 + 5 + Adds support for generating variants of pages for the purpose of internationalization
+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 = [
+200 -11
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 - struct Args { 7 - path: Expr, 6 + enum LocaleKind { 7 + FullPath(Expr), 8 + Prefix(Expr), 8 9 } 9 10 10 - impl Parse for Args { 11 + struct LocaleVariant { 12 + locale: Ident, 13 + kind: LocaleKind, 14 + } 15 + 16 + impl Parse for LocaleVariant { 11 17 fn parse(input: ParseStream) -> Result<Self> { 12 - let path = input.parse()?; 18 + let locale = input.parse::<Ident>()?; 19 + 20 + // Check if it's `locale = "path"`, `locale(path = "path")`, or `locale(prefix = "path")` 21 + let lookahead = input.lookahead1(); 22 + 23 + let kind = if lookahead.peek(Token![=]) { 24 + // Shorthand full path: `en = "/en/about"` 25 + input.parse::<Token![=]>()?; 26 + let path = input.parse::<Expr>()?; 27 + LocaleKind::FullPath(path) 28 + } else if lookahead.peek(syn::token::Paren) { 29 + // Either `en(path = "...")` or `en(prefix = "...")` 30 + let content; 31 + syn::parenthesized!(content in input); 32 + 33 + let key_ident: Ident = content.parse()?; 34 + content.parse::<Token![=]>()?; 35 + let value = content.parse::<Expr>()?; 36 + 37 + if key_ident == "path" { 38 + LocaleKind::FullPath(value) 39 + } else if key_ident == "prefix" { 40 + LocaleKind::Prefix(value) 41 + } else { 42 + return Err(content.error("expected 'path' or 'prefix'")); 43 + } 44 + } else { 45 + return Err(lookahead.error()); 46 + }; 47 + 48 + Ok(LocaleVariant { locale, kind }) 49 + } 50 + } 51 + 52 + struct RouteArgs { 53 + path: Option<Expr>, 54 + locales: Vec<LocaleVariant>, 55 + } 56 + 57 + impl Parse for RouteArgs { 58 + fn parse(input: ParseStream) -> Result<Self> { 59 + let mut path = None; 60 + let mut locales = Vec::new(); 61 + 62 + if input.is_empty() { 63 + return Ok(RouteArgs { path, locales }); 64 + } 65 + 66 + // First argument: either a path expression or a named argument like locales(...) 67 + if input.peek(Ident) && input.peek2(syn::token::Paren) { 68 + // If the first argument is a named one, that means there's no base path and this route should only have variants 69 + let ident: Ident = input.parse()?; 70 + let ident_str = ident.to_string(); 71 + 72 + if ident_str == "locales" { 73 + let content; 74 + syn::parenthesized!(content in input); 75 + let variants = Punctuated::<LocaleVariant, Token![,]>::parse_terminated(&content)?; 76 + locales = variants.into_iter().collect(); 77 + } else { 78 + return Err(syn::Error::new_spanned( 79 + ident, 80 + format!("unknown argument '{}', expected 'locales'", ident_str), 81 + )); 82 + } 83 + } else { 84 + // First argument is a path expression, e.g., "/about" so proceed as normal 85 + path = Some(input.parse::<Expr>()?); 86 + } 87 + 88 + // Parse remaining named arguments (right now just locales(...)) 89 + while !input.is_empty() { 90 + input.parse::<Token![,]>()?; 91 + 92 + if input.is_empty() { 93 + break; 94 + } 95 + 96 + // All subsequent arguments must be named (e.g., locales(...), the path must be first) 97 + if input.peek(Ident) && input.peek2(syn::token::Paren) { 98 + let ident: Ident = input.parse()?; 99 + let ident_str = ident.to_string(); 100 + 101 + if ident_str == "locales" { 102 + if !locales.is_empty() { 103 + return Err(syn::Error::new_spanned( 104 + ident, 105 + "locales specified multiple times", 106 + )); 107 + } 108 + let content; 109 + syn::parenthesized!(content in input); 110 + let variants = 111 + Punctuated::<LocaleVariant, Token![,]>::parse_terminated(&content)?; 112 + locales = variants.into_iter().collect(); 113 + } else { 114 + return Err(syn::Error::new_spanned( 115 + ident, 116 + format!("unknown argument '{}'", ident_str), 117 + )); 118 + } 119 + } else { 120 + return Err(syn::Error::new( 121 + input.span(), 122 + "expected named argument (e.g., locales(...)), path must be first argument", 123 + )); 124 + } 125 + } 126 + 127 + // Check for duplicate locales 128 + Self::check_duplicate_locales(&locales)?; 129 + 130 + Ok(RouteArgs { path, locales }) 131 + } 132 + } 133 + 134 + impl RouteArgs { 135 + fn check_duplicate_locales(locales: &[LocaleVariant]) -> Result<()> { 136 + use std::collections::HashSet; 137 + let mut seen = HashSet::new(); 138 + 139 + for variant in locales { 140 + let locale_name = variant.locale.to_string(); 141 + if !seen.insert(locale_name.clone()) { 142 + return Err(syn::Error::new_spanned( 143 + &variant.locale, 144 + format!("duplicate locale '{}' specified", locale_name), 145 + )); 146 + } 147 + } 13 148 14 - Ok(Args { path }) 149 + Ok(()) 15 150 } 16 151 } 17 152 ··· 19 154 pub fn route(attrs: TokenStream, item: TokenStream) -> TokenStream { 20 155 // Parse the input tokens into a syntax tree 21 156 let item_struct = syn::parse_macro_input!(item as ItemStruct); 22 - let attrs = syn::parse_macro_input!(attrs as Args); 157 + let args = syn::parse_macro_input!(attrs as RouteArgs); 23 158 24 159 let struct_name = &item_struct.ident; 25 - let path = &attrs.path; 160 + 161 + // Generate variants method based on locales 162 + let variant_method = if !args.locales.is_empty() { 163 + let variant_tuples = args.locales.iter().map(|variant| { 164 + let locale_name = variant.locale.to_string(); 165 + 166 + match &variant.kind { 167 + LocaleKind::FullPath(path) => { 168 + quote! { 169 + (#locale_name.to_string(), #path.to_string()) 170 + } 171 + } 172 + LocaleKind::Prefix(prefix) => { 173 + if args.path.is_none() { 174 + // Emit compile error if prefix is used without base path 175 + quote! { 176 + compile_error!("Cannot use locale prefix without a base route path") 177 + } 178 + } else { 179 + let base_path = args.path.as_ref().unwrap(); 180 + quote! { 181 + (#locale_name.to_string(), format!("{}{}", #prefix, #base_path)) 182 + } 183 + } 184 + } 185 + } 186 + }); 187 + 188 + quote! { 189 + fn variants(&self) -> Vec<(String, String)> { 190 + vec![#(#variant_tuples),*] 191 + } 192 + } 193 + } else { 194 + quote! { 195 + fn variants(&self) -> Vec<(String, String)> { 196 + vec![] 197 + } 198 + } 199 + }; 200 + 201 + // Generate route_raw implementation based on whether path is provided 202 + let route_raw_impl = if let Some(path) = &args.path { 203 + quote! { 204 + fn route_raw(&self) -> Option<String> { 205 + Some(#path.to_string()) 206 + } 207 + } 208 + } else { 209 + quote! { 210 + fn route_raw(&self) -> Option<String> { 211 + None 212 + } 213 + } 214 + }; 26 215 27 216 let expanded = quote! { 28 217 impl maudit::route::InternalRoute for #struct_name { 29 - fn route_raw(&self) -> String { 30 - #path.to_string() 31 - } 218 + #route_raw_impl 219 + 220 + #variant_method 32 221 } 33 222 34 223 impl maudit::route::FullRoute for #struct_name {
+142 -37
crates/maudit/src/build.rs
··· 15 15 content::ContentSources, 16 16 is_dev, 17 17 logging::print_title, 18 - route::{ 19 - CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams, 20 - RouteType, 21 - }, 18 + route::{CachedRoute, DynamicRouteContext, FullRoute, InternalRoute, PageContext, PageParams}, 19 + routing::extract_params_from_raw_route, 22 20 }; 23 21 use colored::{ColoredString, Colorize}; 24 22 use log::{debug, info, trace, warn}; ··· 129 127 // how fast most sites build. Ideally, it'd be configurable and default to serial, but I haven't found an ergonomic way to do that yet. 130 128 // If you manage to make it parallel and it actually improves performance, please open a PR! 131 129 for route in routes { 130 + let route_start = Instant::now(); 132 131 let cached_route = CachedRoute::new(*route); 132 + let base_path = route.route_raw(); 133 + let variants = cached_route.variants(); 133 134 134 - match cached_route.route_type() { 135 - RouteType::Static => { 136 - let route_start = Instant::now(); 135 + trace!(target: "build", "Processing route: base='{}', variants={}", base_path.as_deref().unwrap_or(""), variants.len()); 136 + 137 + let has_base_route = base_path.is_some(); 137 138 139 + if !has_base_route && !variants.is_empty() { 140 + info!(target: "pages", "(variants only)"); 141 + } 142 + 143 + // Handle base route 144 + if let Some(ref base_path) = base_path { 145 + let base_params = extract_params_from_raw_route(base_path); 146 + 147 + // Static base route 148 + if base_params.is_empty() { 138 149 let mut route_assets = 139 150 RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 140 151 ··· 146 157 &mut route_assets, 147 158 &url, 148 159 &options.base_url, 160 + None, 149 161 ))?; 150 162 151 163 let file_path = cached_route.file_path(&params, &options.output_dir); ··· 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 => { 170 - let mut page_assets = 180 + } else { 181 + // Dynamic base route 182 + let mut route_assets = 171 183 RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 172 - 173 184 let pages = route.get_pages(&mut DynamicRouteContext { 174 185 content: content_sources, 175 - assets: &mut page_assets, 186 + assets: &mut route_assets, 187 + variant: None, 176 188 }); 177 189 178 190 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()); 191 + 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.", base_path.bold()); 180 192 continue; 181 193 } else { 182 - info!(target: "build", "{}", route.route_raw().to_string().bold()); 194 + // Log the pattern first 195 + info!(target: "pages", "{}", base_path); 196 + 197 + // Build all pages for this route 198 + for page in pages { 199 + let url = cached_route.url(&page.0); 200 + let file_path = cached_route.file_path(&page.0, &options.output_dir); 201 + 202 + let content = route.build(&mut PageContext::from_dynamic_route( 203 + &page, 204 + content_sources, 205 + &mut route_assets, 206 + &url, 207 + &options.base_url, 208 + None, 209 + ))?; 210 + 211 + write_route_file(&content, &file_path)?; 212 + 213 + info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 214 + 215 + build_metadata.add_page( 216 + base_path.clone(), 217 + file_path.to_string_lossy().to_string(), 218 + Some(page.0.0.clone()), 219 + ); 220 + 221 + page_count += 1; 222 + } 183 223 } 184 224 185 - for page in pages { 186 - let route_start = Instant::now(); 225 + build_pages_images.extend(route_assets.images); 226 + build_pages_scripts.extend(route_assets.scripts); 227 + build_pages_styles.extend(route_assets.styles); 228 + } 229 + } 230 + 231 + // Handle variants 232 + for (variant_id, variant_path) in variants { 233 + let variant_start = Instant::now(); 234 + let variant_params = extract_params_from_raw_route(&variant_path); 235 + 236 + if variant_params.is_empty() { 237 + // Static variant 238 + let mut route_assets = 239 + RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 240 + 241 + let params = PageParams::default(); 242 + let url = cached_route.variant_url(&params, &variant_id)?; 243 + let file_path = 244 + cached_route.variant_file_path(&params, &options.output_dir, &variant_id)?; 245 + 246 + let result = route.build(&mut PageContext::from_static_route( 247 + content_sources, 248 + &mut route_assets, 249 + &url, 250 + &options.base_url, 251 + Some(variant_id.clone()), 252 + ))?; 253 + 254 + write_route_file(&result, &file_path)?; 255 + 256 + info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options)); 257 + 258 + build_pages_images.extend(route_assets.images); 259 + build_pages_scripts.extend(route_assets.scripts); 260 + build_pages_styles.extend(route_assets.styles); 261 + 262 + build_metadata.add_page( 263 + variant_path.clone(), 264 + file_path.to_string_lossy().to_string(), 265 + None, 266 + ); 267 + 268 + page_count += 1; 269 + } else { 270 + // Dynamic variant 271 + let mut route_assets = 272 + RouteAssets::new(&route_assets_options, Some(image_cache.clone())); 273 + let pages = route.get_pages(&mut DynamicRouteContext { 274 + content: content_sources, 275 + assets: &mut route_assets, 276 + variant: Some(&variant_id), 277 + }); 187 278 188 - let url = cached_route.url(&page.0); 279 + if pages.is_empty() { 280 + warn!(target: "build", "Variant {} has dynamic parameters but Route::pages returned an empty Vec.", variant_id.bold()); 281 + } else { 282 + // Log the variant pattern first 283 + info!(target: "pages", "├─ {}", variant_path); 189 284 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 - ))?; 285 + // Build all pages for this variant group 286 + for page in pages { 287 + let variant_page_start = Instant::now(); 288 + let url = cached_route.variant_url(&page.0, &variant_id)?; 289 + let file_path = cached_route.variant_file_path( 290 + &page.0, 291 + &options.output_dir, 292 + &variant_id, 293 + )?; 197 294 198 - let file_path = cached_route.file_path(&page.0, &options.output_dir); 295 + let content = route.build(&mut PageContext::from_dynamic_route( 296 + &page, 297 + content_sources, 298 + &mut route_assets, 299 + &url, 300 + &options.base_url, 301 + Some(variant_id.clone()), 302 + ))?; 199 303 200 - write_route_file(&content, &file_path)?; 304 + write_route_file(&content, &file_path)?; 201 305 202 - info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 306 + info!(target: "pages", "│ ├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options)); 203 307 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 - ); 308 + build_metadata.add_page( 309 + variant_path.clone(), 310 + file_path.to_string_lossy().to_string(), 311 + Some(page.0.0.clone()), 312 + ); 209 313 210 - page_count += 1; 314 + page_count += 1; 315 + } 211 316 } 212 317 213 - build_pages_images.extend(page_assets.images); 214 - build_pages_scripts.extend(page_assets.scripts); 215 - build_pages_styles.extend(page_assets.styles); 318 + build_pages_images.extend(route_assets.images); 319 + build_pages_scripts.extend(route_assets.scripts); 320 + build_pages_styles.extend(route_assets.styles); 216 321 } 217 322 } 218 323 }
+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)
+1 -2
crates/maudit/src/lib.rs
··· 10 10 pub mod content; 11 11 pub mod errors; 12 12 pub mod route; 13 - 14 - mod routing; 13 + pub mod routing; 15 14 16 15 // Exports for end-users 17 16 pub use build::metadata::{BuildOutput, PageOutput, StaticAssetOutput};
+184 -26
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 288 Self { 288 289 params: &(), ··· 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. ··· 463 470 /// Used internally by Maudit and should not be implemented by the user. 464 471 /// We expose it because the derive macro implements it for the user behind the scenes. 465 472 pub trait InternalRoute { 466 - fn route_raw(&self) -> String; 473 + fn route_raw(&self) -> Option<String>; 474 + 475 + fn variants(&self) -> Vec<(String, String)> { 476 + vec![] 477 + } 478 + 467 479 fn is_endpoint(&self) -> bool { 468 - guess_if_route_is_endpoint(&self.route_raw()) 480 + self.route_raw() 481 + .as_ref() 482 + .map(|path| guess_if_route_is_endpoint(path)) 483 + .unwrap_or(false) 469 484 } 485 + 486 + #[deprecated] 470 487 fn route_type(&self) -> RouteType { 471 - let params_def = extract_params_from_raw_route(&self.route_raw()); 488 + let path = self.route_raw().unwrap_or_default(); 489 + let params_def = extract_params_from_raw_route(&path); 472 490 473 - get_route_type_from_route_params(&params_def) 491 + // Check if base route is dynamic 492 + if !params_def.is_empty() { 493 + return RouteType::Dynamic; 494 + } 495 + 496 + // Check if any variant is dynamic 497 + let variants = self.variants(); 498 + for (_id, variant_path) in variants { 499 + let variant_params = extract_params_from_raw_route(&variant_path); 500 + if !variant_params.is_empty() { 501 + return RouteType::Dynamic; 502 + } 503 + } 504 + 505 + RouteType::Static 474 506 } 475 507 476 508 fn url(&self, params: &PageParams) -> String { 477 - let params_def = extract_params_from_raw_route(&self.route_raw()); 478 - build_url_with_params(&self.route_raw(), &params_def, params, self.is_endpoint()) 509 + let route = self.route_raw().unwrap_or_default(); 510 + let params_def = extract_params_from_raw_route(&route); 511 + build_url_with_params(&route, &params_def, params, self.is_endpoint()) 512 + } 513 + 514 + fn variant_url(&self, params: &PageParams, variant: &str) -> Result<String, String> { 515 + let variants = self.variants(); 516 + let variant_path = variants 517 + .iter() 518 + .find(|(id, _)| id == variant) 519 + .map(|(_, path)| path.clone()) 520 + .ok_or_else(|| format!("Variant '{}' not found", variant))?; 521 + let is_endpoint = guess_if_route_is_endpoint(&variant_path); 522 + let params_def = extract_params_from_raw_route(&variant_path); 523 + Ok(build_url_with_params( 524 + &variant_path, 525 + &params_def, 526 + params, 527 + is_endpoint, 528 + )) 479 529 } 480 530 481 531 fn file_path(&self, params: &PageParams, output_dir: &Path) -> PathBuf { 482 - let params_def = extract_params_from_raw_route(&self.route_raw()); 483 - build_file_path_with_params( 484 - &self.route_raw(), 532 + let route = self.route_raw().unwrap_or_default(); 533 + let params_def = extract_params_from_raw_route(&route); 534 + build_file_path_with_params(&route, &params_def, params, output_dir, self.is_endpoint()) 535 + } 536 + 537 + fn variant_file_path( 538 + &self, 539 + params: &PageParams, 540 + output_dir: &Path, 541 + variant: &str, 542 + ) -> Result<PathBuf, String> { 543 + let variants = self.variants(); 544 + let variant_path = variants 545 + .iter() 546 + .find(|(id, _)| id == variant) 547 + .map(|(_, path)| path.clone()) 548 + .ok_or_else(|| format!("Variant '{}' not found", variant))?; 549 + let is_endpoint = guess_if_route_is_endpoint(&variant_path); 550 + let params_def = extract_params_from_raw_route(&variant_path); 551 + Ok(build_file_path_with_params( 552 + &variant_path, 485 553 &params_def, 486 554 params, 487 555 output_dir, 488 - self.is_endpoint(), 489 - ) 556 + is_endpoint, 557 + )) 490 558 } 491 559 } 492 560 ··· 502 570 fn url(&self, params: Params) -> String { 503 571 InternalRoute::url(self, &params.into()) 504 572 } 573 + 574 + /// Get the URL for this page with the given parameters and variant 575 + /// 576 + /// Returns an error if the variant does not exist on this route. 577 + /// 578 + /// # Example 579 + /// ```rust,ignore 580 + /// let url = route.variant_url(params, "en")?; 581 + /// ``` 582 + fn variant_url(&self, params: Params, variant: &str) -> Result<String, String> { 583 + InternalRoute::variant_url(self, &params.into(), variant) 584 + } 505 585 } 506 586 507 587 // Blanket implementation for all Page implementors that also implement InternalPage ··· 530 610 531 611 fn build(&self, ctx: &mut PageContext) -> Result<Vec<u8>, Box<dyn std::error::Error>> { 532 612 let result = self.render_internal(ctx)?; 533 - let bytes = finish_route(result, ctx.assets, self.route_raw())?; 613 + let bytes = finish_route(result, ctx.assets, self.route_raw().unwrap_or_default())?; 534 614 535 615 Ok(bytes) 536 616 } ··· 541 621 542 622 // This function and the one below are extremely performance-sensitive, as they are called for every single page during the build. 543 623 // 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( 624 + pub fn build_url_with_params( 545 625 route_template: &str, 546 626 params_def: &[ParameterDef], 547 627 params: &PageParams, ··· 586 666 result 587 667 } 588 668 589 - fn build_file_path_with_params( 669 + pub fn build_file_path_with_params( 590 670 route_template: &str, 591 671 params_def: &[ParameterDef], 592 672 params: &PageParams, ··· 635 715 inner: &'a dyn FullRoute, 636 716 params_cache: OnceLock<Vec<ParameterDef>>, 637 717 is_endpoint: OnceLock<bool>, 718 + variant_caches: OnceLock<FxHashMap<String, (Vec<ParameterDef>, bool)>>, 638 719 } 639 720 640 721 impl<'a> CachedRoute<'a> { ··· 643 724 inner: route, 644 725 params_cache: OnceLock::new(), 645 726 is_endpoint: OnceLock::new(), 727 + variant_caches: OnceLock::new(), 646 728 } 647 729 } 648 730 649 731 fn get_cached_params(&self) -> &Vec<ParameterDef> { 650 - self.params_cache 651 - .get_or_init(|| extract_params_from_raw_route(&self.inner.route_raw())) 732 + self.params_cache.get_or_init(|| { 733 + extract_params_from_raw_route(&self.inner.route_raw().unwrap_or_default()) 734 + }) 652 735 } 653 736 654 737 fn is_endpoint(&self) -> bool { 655 738 *self 656 739 .is_endpoint 657 - .get_or_init(|| guess_if_route_is_endpoint(&self.inner.route_raw())) 740 + .get_or_init(|| guess_if_route_is_endpoint(&self.inner.route_raw().unwrap_or_default())) 741 + } 742 + 743 + fn get_variant_cache(&self, variant_id: &str) -> Option<&(Vec<ParameterDef>, bool)> { 744 + let variant_caches = self.variant_caches.get_or_init(|| { 745 + let mut map = FxHashMap::default(); 746 + for (id, path) in self.inner.variants() { 747 + let params = extract_params_from_raw_route(&path); 748 + let is_endpoint = guess_if_route_is_endpoint(&path); 749 + map.insert(id, (params, is_endpoint)); 750 + } 751 + map 752 + }); 753 + 754 + variant_caches.get(variant_id) 658 755 } 659 756 } 660 757 661 758 impl<'a> InternalRoute for CachedRoute<'a> { 662 - fn route_raw(&self) -> String { 759 + fn route_raw(&self) -> Option<String> { 663 760 self.inner.route_raw() 664 761 } 665 762 763 + fn variants(&self) -> Vec<(String, String)> { 764 + self.inner.variants() 765 + } 766 + 666 767 fn route_type(&self) -> RouteType { 667 - get_route_type_from_route_params(self.get_cached_params()) 768 + // Check if base route is dynamic 769 + let params_def = self.get_cached_params(); 770 + if !params_def.is_empty() { 771 + return RouteType::Dynamic; 772 + } 773 + 774 + // Check if any variant is dynamic 775 + let variants = self.variants(); 776 + for (_id, variant_path) in variants { 777 + let variant_params = extract_params_from_raw_route(&variant_path); 778 + if !variant_params.is_empty() { 779 + return RouteType::Dynamic; 780 + } 781 + } 782 + 783 + RouteType::Static 668 784 } 669 785 670 786 fn url(&self, params: &PageParams) -> String { 671 787 build_url_with_params( 672 - &self.route_raw(), 788 + &self.route_raw().unwrap_or_default(), 673 789 self.get_cached_params(), 674 790 params, 675 791 self.is_endpoint(), 676 792 ) 677 793 } 678 794 795 + fn variant_url(&self, params: &PageParams, variant: &str) -> Result<String, String> { 796 + let (params_def, is_endpoint) = self 797 + .get_variant_cache(variant) 798 + .ok_or_else(|| format!("Variant '{}' not found", variant))?; 799 + let variants = self.inner.variants(); 800 + let variant_path = variants 801 + .iter() 802 + .find(|(id, _)| id == variant) 803 + .map(|(_, path)| path.clone()) 804 + .ok_or_else(|| format!("Variant '{}' not found", variant))?; 805 + Ok(build_url_with_params( 806 + &variant_path, 807 + params_def, 808 + params, 809 + *is_endpoint, 810 + )) 811 + } 812 + 679 813 fn file_path(&self, params: &PageParams, output_dir: &Path) -> PathBuf { 680 814 build_file_path_with_params( 681 - &self.route_raw(), 815 + &self.route_raw().unwrap_or_default(), 682 816 self.get_cached_params(), 683 817 params, 684 818 output_dir, 685 819 self.is_endpoint(), 686 820 ) 821 + } 822 + 823 + fn variant_file_path( 824 + &self, 825 + params: &PageParams, 826 + output_dir: &Path, 827 + variant: &str, 828 + ) -> Result<PathBuf, String> { 829 + let (params_def, is_endpoint) = self 830 + .get_variant_cache(variant) 831 + .ok_or_else(|| format!("Variant '{}' not found", variant))?; 832 + let variants = self.inner.variants(); 833 + let variant_path = variants 834 + .iter() 835 + .find(|(id, _)| id == variant) 836 + .map(|(_, path)| path.clone()) 837 + .ok_or_else(|| format!("Variant '{}' not found", variant))?; 838 + Ok(build_file_path_with_params( 839 + &variant_path, 840 + params_def, 841 + params, 842 + output_dir, 843 + *is_endpoint, 844 + )) 687 845 } 688 846 } 689 847 ··· 786 944 } 787 945 788 946 impl InternalRoute for TestPage { 789 - fn route_raw(&self) -> String { 790 - self.route.clone() 947 + fn route_raw(&self) -> Option<String> { 948 + Some(self.route.clone()) 791 949 } 792 950 } 793 951
+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, Contact, Index}; 6 + 7 + fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 8 + coronate( 9 + routes![Index, About, Contact, Article], 10 + content_sources![], 11 + BuildOptions::default(), 12 + ) 13 + }
+40
examples/i18n/src/routes/about.rs
··· 1 + use crate::layout::layout; 2 + use maud::html; 3 + use maudit::route::prelude::*; 4 + 5 + // Demonstrates all three locale syntax options: 6 + // 1. Shorthand full path: en = "..." 7 + // 2. Explicit full path: sv(path = "...") 8 + // 3. Prefix: de(prefix = "...") 9 + #[route( 10 + "/about", 11 + locales( 12 + en = "/en/about", // Shorthand full path 13 + sv(path = "/sv/om-oss"), // Explicit full path 14 + de(prefix = "/de") // Prefix (becomes /de/about) 15 + ) 16 + )] 17 + pub struct About; 18 + 19 + impl Route for About { 20 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 21 + layout(html! { 22 + h1 { "About" } 23 + p { "This route demonstrates all locale syntax variations:" } 24 + ul { 25 + li { code { "en = \"/en/about\"" } " - shorthand full path" } 26 + li { code { "sv(path = \"/sv/om-oss\")" } " - explicit full path" } 27 + li { code { "de(prefix = \"/de\")" } " - prefix (becomes /de/about)" } 28 + } 29 + nav { 30 + h3 { "Generated routes:" } 31 + ul { 32 + li { a href="/about" { "Default (/about)" } } 33 + li { a href="/en/about" { "English (/en/about)" } } 34 + li { a href="/sv/om-oss" { "Swedish (/sv/om-oss)" } } 35 + li { a href="/de/about" { "German (/de/about)" } } 36 + } 37 + } 38 + }) 39 + } 40 + }
+50
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 + #[route( 11 + "/articles/[slug]", 12 + locales(en = "/en/articles/[slug]", sv = "/sv/artiklar/[slug]") 13 + )] 14 + pub struct Article; 15 + 16 + impl Route<ArticleParams> for Article { 17 + fn pages(&self, _ctx: &mut DynamicRouteContext) -> Pages<ArticleParams> { 18 + vec![ 19 + Page::from_params(ArticleParams { 20 + slug: "hello-world".to_string(), 21 + }), 22 + Page::from_params(ArticleParams { 23 + slug: "getting-started".to_string(), 24 + }), 25 + ] 26 + } 27 + 28 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 29 + let params = ctx.params::<ArticleParams>(); 30 + 31 + let variant_info = if let Some(variant) = &ctx.variant { 32 + format!("Variant: {}", variant) 33 + } else { 34 + "Base route (no variant)".to_string() 35 + }; 36 + 37 + layout(html! { 38 + h1 { "Article: " (params.slug) } 39 + p { (variant_info) } 40 + p { "This is a dynamic route with localized variants." } 41 + nav { 42 + ul { 43 + li { a href="/articles/hello-world" { "Default" } } 44 + li { a href="/en/articles/hello-world" { "English" } } 45 + li { a href="/sv/artiklar/hello-world" { "Swedish" } } 46 + } 47 + } 48 + }) 49 + } 50 + }
+21
examples/i18n/src/routes/contact.rs
··· 1 + use maudit::route::prelude::*; 2 + 3 + #[route( 4 + "/contact", 5 + locales(en(prefix = "/en"), sv(prefix = "/sv"), de(path = "/de/kontakt")) 6 + )] 7 + pub struct Contact; 8 + 9 + impl Route for Contact { 10 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 11 + match &ctx.variant { 12 + Some(language) => match language.as_str() { 13 + "en" => "Contact us.", 14 + "sv" => "Kontakta oss.", 15 + "de" => "Kontaktieren Sie uns.", 16 + _ => unreachable!(), 17 + }, 18 + _ => "Contact us.", 19 + } 20 + } 21 + }
+27
examples/i18n/src/routes/index.rs
··· 1 + use crate::layout::layout; 2 + use maud::html; 3 + use maudit::route::prelude::*; 4 + 5 + #[route(locales(en = "/en", sv = "/sv", de = "/de"))] 6 + pub struct Index; 7 + 8 + impl Route for Index { 9 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 10 + layout(html! { 11 + h1 { "i18n Example" } 12 + p { "This route only exists as variants - no base path!" } 13 + p { "The current variant is: " (if let Some(variant) = &_ctx.variant { 14 + variant 15 + } else { 16 + "none" 17 + }) } 18 + nav { 19 + ul { 20 + li { a href="/en/" { "English" } } 21 + li { a href="/sv/" { "Swedish" } } 22 + li { a href="/de/" { "German" } } 23 + } 24 + } 25 + }) 26 + } 27 + }
+9
examples/i18n/src/routes/mod.rs
··· 1 + mod about; 2 + mod articles; 3 + mod contact; 4 + mod index; 5 + 6 + pub use about::About; 7 + pub use articles::Article; 8 + pub use contact::Contact; 9 + pub use index::Index;
+26
examples/i18n/test_errors.txt
··· 1 + Test cases for error handling: 2 + 3 + 1. Duplicate locales error: 4 + #[route("/about", locales(en = "/en"), locales(sv = "/sv"))] 5 + Expected error: "locales specified multiple times" 6 + 7 + 2. Duplicate locale within locales: 8 + #[route("/about", locales(en = "/en", en = "/english"))] 9 + Expected error: "duplicate locale 'en' specified" 10 + 11 + 3. Unknown argument: 12 + #[route("/about", something(foo = "bar"))] 13 + Expected error: "unknown argument 'something'" 14 + 15 + 4. Prefix without base path: 16 + #[route(locales(en(prefix = "/en")))] 17 + Expected error: "Cannot use locale prefix without a base route path" 18 + 19 + 5. Path in wrong position: 20 + #[route(locales(en = "/en"), "/about")] 21 + Expected error: "expected named argument (e.g., locales(...)), path must be first argument" 22 + 23 + All these cases should produce compile-time errors with clear messages. 24 + The refactored parser enforces that the path must be the first argument (if present), 25 + followed by named arguments like locales(...). This is flexible enough to support 26 + future named arguments while maintaining clear syntax rules.
+77 -63
examples/library/src/build.rs
··· 4 4 BuildOptions, 5 5 assets::RouteAssets, 6 6 content::ContentSources, 7 - route::{DynamicRouteContext, FullRoute, PageContext, PageParams, RouteType}, 7 + route::{DynamicRouteContext, FullRoute, PageContext, PageParams}, 8 + routing::extract_params_from_raw_route, 8 9 }; 9 10 10 11 pub fn build_website( ··· 12 13 mut content_sources: ContentSources, 13 14 options: &BuildOptions, 14 15 ) -> Result<(), Box<dyn std::error::Error>> { 15 - // Initialize all the content sources; 16 + // Initialize all the content sources 16 17 content_sources.init_all(); 17 18 18 19 // Options we'll be passing to RouteAssets instances. ··· 20 21 let route_assets_options = options.route_assets_options(); 21 22 22 23 // Create the assets directory if it doesn't exist. 23 - fs::create_dir_all(&route_assets_options.assets_dir)?; 24 + fs::create_dir_all(&route_assets_options.output_assets_dir)?; 24 25 25 26 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 mut page_assets = RouteAssets::new(&route_assets_options, None); 27 + // Get the raw route path (e.g., "/articles/[slug]") 28 + let Some(route_path) = route.route_raw() else { 29 + // Skip routes without a base path (variants-only routes) 30 + continue; 31 + }; 30 32 31 - // Static and dynamic routes share the same interface for building, but static routes do not require any parameters. 32 - // As such, we can just pass an empty set of parameters (the default for PageParams). 33 - let params = PageParams::default(); 33 + // Extract parameters from the route path to determine if it's static or dynamic 34 + let params = extract_params_from_raw_route(&route_path); 34 35 35 - // Every page has a PageContext, which contains information about the current page, as well as access to content and assets. 36 - let url = route.url(&params); 37 - let mut ctx = PageContext::from_static_route( 38 - &content_sources, 39 - &mut page_assets, 40 - &url, 41 - &options.base_url, 42 - ); 36 + if params.is_empty() { 37 + // Static route - no parameters in the path 43 38 44 - let content = route.build(&mut ctx)?; 39 + // Our page does not include content or assets, but we'll set those up for future use. 40 + let mut page_assets = RouteAssets::new(&route_assets_options, None); 45 41 46 - let page_filepath = route.file_path(&params, &options.output_dir); 42 + // Static routes do not require any parameters. 43 + // As such, we can just pass an empty set of parameters (the default for PageParams). 44 + let params = PageParams::default(); 47 45 48 - // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail. 49 - if let Some(parent_dir) = page_filepath.parent() { 50 - fs::create_dir_all(parent_dir)? 51 - } 46 + // Every page has a PageContext, which contains information about the current page, as well as access to content and assets. 47 + let url = route.url(&params); 48 + let mut ctx = PageContext::from_static_route( 49 + &content_sources, 50 + &mut page_assets, 51 + &url, 52 + &options.base_url, 53 + None, 54 + ); 55 + 56 + let content = route.build(&mut ctx)?; 52 57 53 - fs::write(page_filepath, content)?; 58 + let page_filepath = route.file_path(&params, &options.output_dir); 54 59 55 - // Copy all assets used by this page. 56 - for asset in page_assets.assets() { 57 - fs::copy(asset.path(), asset.build_path())?; 58 - } 60 + // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail. 61 + if let Some(parent_dir) = page_filepath.parent() { 62 + fs::create_dir_all(parent_dir)? 59 63 } 60 - RouteType::Dynamic => { 61 - // Every page of a dynamic route may share a reference to the same RouteAssets instance, as it can help with caching. 62 - // However, it is not stricly necessary, and you may want to instead create a new instance of RouteAssets especially if you were to parallelize the building of pages. 63 - let mut page_assets = RouteAssets::new(&route_assets_options, None); 64 64 65 - // The `get_pages` method returns all the possible pages for this route, along with their parameters and properties. 66 - // 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. 67 - // As such, we create essentially a mini `PageContext` through `DynamicRouteContext` that includes the content sources, so that the page can use them to generate its routes. 68 - let mut dynamic_ctx = DynamicRouteContext { 69 - content: &content_sources, 70 - assets: &mut page_assets, 71 - }; 65 + fs::write(page_filepath, content)?; 72 66 73 - let routes = route.get_pages(&mut dynamic_ctx); 67 + // Copy all assets used by this page. 68 + for asset in page_assets.assets() { 69 + fs::copy(asset.path(), asset.build_path())?; 70 + } 71 + } else { 72 + // Dynamic route - has parameters like [slug] or [id] 74 73 75 - for page in routes { 76 - // The dynamic route includes the parameters for this specific page. 77 - let params = &page.0; 74 + // Every page of a dynamic route may share a reference to the same RouteAssets instance, as it can help with caching. 75 + // However, it is not strictly necessary, and you may want to instead create a new instance of RouteAssets especially if you were to parallelize the building of pages. 76 + let mut page_assets = RouteAssets::new(&route_assets_options, None); 78 77 79 - // Here the context is created from a dynamic route, as the context has to include the page parameters and properties. 80 - 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 - ); 78 + // The `get_pages` method returns all the possible pages for this route, along with their parameters and properties. 79 + // 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. 80 + // As such, we create essentially a mini `PageContext` through `DynamicRouteContext` that includes the content sources, so that the page can use them to generate its routes. 81 + let mut dynamic_ctx = DynamicRouteContext { 82 + content: &content_sources, 83 + assets: &mut page_assets, 84 + variant: None, 85 + }; 88 86 89 - // Everything below here is the same as for static routes. 87 + let routes = route.get_pages(&mut dynamic_ctx); 88 + 89 + for page in routes { 90 + // The dynamic route includes the parameters for this specific page. 91 + let params = &page.0; 92 + 93 + // Here the context is created from a dynamic route, as the context has to include the page parameters and properties. 94 + let url = route.url(params); 95 + let mut ctx = PageContext::from_dynamic_route( 96 + &page, 97 + &content_sources, 98 + &mut page_assets, 99 + &url, 100 + &options.base_url, 101 + None, 102 + ); 103 + 104 + // Everything below here is the same as for static routes. 90 105 91 - let content = route.build(&mut ctx)?; 106 + let content = route.build(&mut ctx)?; 92 107 93 - let route_filepath = route.file_path(params, &options.output_dir); 108 + let route_filepath = route.file_path(params, &options.output_dir); 94 109 95 - if let Some(parent_dir) = route_filepath.parent() { 96 - fs::create_dir_all(parent_dir)? 97 - } 110 + if let Some(parent_dir) = route_filepath.parent() { 111 + fs::create_dir_all(parent_dir)? 112 + } 98 113 99 - fs::write(route_filepath, content)?; 114 + fs::write(route_filepath, content)?; 100 115 101 - for asset in page_assets.assets() { 102 - fs::copy(asset.path(), asset.build_path())?; 103 - } 116 + for asset in page_assets.assets() { 117 + fs::copy(asset.path(), asset.build_path())?; 104 118 } 105 119 } 106 120 }
+46 -33
website/content/docs/library.md
··· 17 17 ```rs 18 18 use maudit::{ 19 19 content::ContentSources, 20 - page::{FullRoute, RouteAssets}, 21 - routing::{DynamicRouteContext, PageContext, PageParams, RouteType}, 20 + route::{DynamicRouteContext, FullRoute, PageContext, PageParams}, 21 + routing::extract_params_from_raw_route, 22 22 BuildOptions, 23 23 }; 24 24 25 25 pub fn build_website( 26 26 routes: &[&dyn FullRoute], 27 27 mut content_sources: ContentSources, 28 - options: BuildOptions 28 + options: &BuildOptions 29 29 ) -> Result<(), Box<dyn std::error::Error>> { 30 30 // We'll fill this in later. 31 31 Ok(()) ··· 36 36 37 37 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. 38 38 39 + To determine if a route is static or dynamic, we get its raw route path using `route_raw()` and check if it contains any parameters using `extract_params_from_raw_route()`. 40 + 39 41 ```rs 40 42 pub fn build_website( 41 43 routes: &[&dyn FullRoute], 42 44 mut content_sources: ContentSources, 43 - options: BuildOptions, 45 + options: &BuildOptions, 44 46 ) -> Result<(), Box<dyn std::error::Error>> { 45 47 46 48 // Options we'll be passing to RouteAssets instances. ··· 48 50 let route_assets_options = options.route_assets_options(); 49 51 50 52 for route in routes { 51 - match route.route_type() { 52 - RouteType::Static => { 53 - // Our page does not include content or assets, but we'll set those up for future use. 54 - // RouteAssets also can take a cache parameter, but we'll leave it empty for simplicity. 55 - let mut route_assets = RouteAssets::new(&route_assets_options, None); 53 + // Get the raw route path (e.g., "/articles/[slug]") 54 + let Some(route_path) = route.route_raw() else { 55 + // Skip routes without a base path (variants-only routes) 56 + continue; 57 + }; 56 58 57 - // Static and dynamic routes share the same interface for building, but static routes do not require any parameters. 58 - // As such, we can just pass an empty set of parameters (the default for PageParams). 59 - let params = PageParams::default(); 59 + // Extract parameters from the route path to determine if it's static or dynamic 60 + let params = extract_params_from_raw_route(&route_path); 60 61 61 - // Every page has a PageContext, which contains information about the current route, as well as access to content and assets. 62 - let url = route.url(&params); 63 - let mut ctx = PageContext::from_static_route(&content_sources, &mut route_assets, &url, &options.base_url); 62 + if params.is_empty() { 63 + // Static route - no parameters in the path 64 64 65 - let content = route.build(&mut ctx)?; 65 + // Our page does not include content or assets, but we'll set those up for future use. 66 + // RouteAssets can take an optional image cache parameter, but we'll leave it as None for simplicity. 67 + let mut route_assets = RouteAssets::new(&route_assets_options, None); 68 + 69 + // Static and dynamic routes share the same interface for building, but static routes do not require any parameters. 70 + // As such, we can just pass an empty set of parameters (the default for PageParams). 71 + let params = PageParams::default(); 72 + 73 + // Every page has a PageContext, which contains information about the current route, as well as access to content and assets. 74 + let url = route.url(&params); 75 + let mut ctx = PageContext::from_static_route(&content_sources, &mut route_assets, &url, &options.base_url, None); 66 76 67 - let route_filepath = route.file_path(&params, &options.output_dir); 77 + let content = route.build(&mut ctx)?; 68 78 69 - // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail. 70 - if let Some(parent_dir) = route_filepath.parent() { 71 - fs::create_dir_all(parent_dir)? 72 - } 79 + let route_filepath = route.file_path(&params, &options.output_dir); 73 80 74 - fs::write(route_filepath, content)?; 81 + // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail. 82 + if let Some(parent_dir) = route_filepath.parent() { 83 + fs::create_dir_all(parent_dir)? 75 84 } 76 - RouteType::Dynamic => { 77 - unimplemented!("We'll handle dynamic routes later"); 78 - } 85 + 86 + fs::write(route_filepath, content)?; 87 + } else { 88 + // We'll handle dynamic routes later 89 + unimplemented!("Dynamic routes not yet implemented"); 79 90 } 80 91 } 81 92 ··· 124 135 ```rs 125 136 // No changes before this block. 126 137 127 - RouteType::Dynamic => { 138 + } else { 128 139 // Every page of a dynamic route may share a reference to the same RouteAssets instance, as it can help with caching. 129 - // However, it is not stricly necessary, and you may want to instead create a new instance of RouteAssets especially if you were to parallelize the building of pages. 140 + // However, it is not strictly necessary, and you may want to instead create a new instance of RouteAssets especially if you were to parallelize the building of pages. 130 141 let mut page_assets = RouteAssets::new(&route_assets_options, None); 131 142 132 143 // The `get_pages` method returns all the possible pages for this route, along with their parameters and properties. ··· 135 146 let mut dynamic_ctx = DynamicRouteContext { 136 147 content: &content_sources, 137 148 assets: &mut page_assets, 149 + variant: None, 138 150 }; 139 151 140 - let routes = route.get_pages(&dynamic_ctx); 152 + let pages = route.get_pages(&dynamic_ctx); 141 153 142 - for dynamic_route in routes { 154 + for page in pages { 143 155 // The dynamic route includes the parameters for this specific route. 144 - let params = &dynamic_route.0; 156 + let params = &page.0; 145 157 146 158 // Here the context is created from a dynamic route, as the context has to include the route parameters and properties. 147 159 let url = route.url(params); 148 160 let mut ctx = PageContext::from_dynamic_route( 149 - &dynamic_route, 161 + &page, 150 162 &content_sources, 151 163 &mut page_assets, 152 164 &url, 153 - &options.base_url 165 + &options.base_url, 166 + None, 154 167 ); 155 168 156 169 // Everything after this is the same as for static routes. ··· 160 173 161 174 ## Conclusion 162 175 163 - 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 (from [`options.static_dir`](https://docs.rs/maudit/latest/maudit/struct.BuildOptions.html#structfield.static_dir)), asset processing, better error handling, parallelization, caching, etc, etc. 176 + And with that, you've successfully rebuilt Maudit at home! There's a few more things that can be done to improve this implementation, like adding logging, adding support [for variants](/docs/routing/#internationalization-i18n), copying static assets (from [`options.static_dir`](https://docs.rs/maudit/latest/maudit/struct.BuildOptions.html#structfield.static_dir)), asset processing, better error handling, parallelization, caching, etc, etc. 164 177 165 178 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.
+39
website/content/docs/routing.md
··· 159 159 } 160 160 } 161 161 ``` 162 + 163 + ## Internationalization (i18n) 164 + 165 + Maudit includes the ability to generate *variants* of pages based on locales. For instance, you may have a `/about` page and want to create a `/fr/about` or `/a-propos` page with a localized slug. 166 + 167 + While you could do this by duplicating your `/about` page, creating a new struct, re-implementing Route etc etc, it would be quite time consuming if your website support more languages and probably lead to a lot of duplicated code, as your Swedish about page probably uses a lot of the same layout as your Danish one. 168 + 169 + To create these variants, specify the named `locales` attribute on your route, after the path: 170 + 171 + ```rs 172 + use maudit::route::prelude::*; 173 + 174 + #[route( 175 + "/contact", 176 + locales(sv(prefix = "/sv"), de(path = "/de/kontakt")) 177 + )] 178 + pub struct Contact; 179 + 180 + impl Route for Contact { 181 + fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> { 182 + match &ctx.variant { 183 + Some(language) => match language.as_str() { 184 + "sv" => "Kontakta oss.", 185 + "de" => "Kontaktieren Sie uns.", 186 + _ => unreachable!(), 187 + }, 188 + _ => "Contact us.", 189 + } 190 + } 191 + } 192 + ``` 193 + 194 + For this example, Maudit will generate three pages: 195 + 196 + - `/contact` 197 + - `/sv/contact` 198 + - `/de/kontakt` 199 + 200 + Calling `render()` three times with a different `ctx.variant` each time.