Rust library to generate static websites
5
fork

Configure Feed

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

fix: use a cleaner pattern for locales

+189 -109
+128 -90
crates/maudit-macros/src/lib.rs
··· 3 3 use syn::parse::{self, Parse, ParseStream, Parser as _, Result}; 4 4 use syn::{Expr, Ident, ItemStruct, Token, parse_macro_input, punctuated::Punctuated}; 5 5 6 - struct Args { 7 - path: Option<Expr>, 8 - } 9 - 10 - impl Parse for Args { 11 - fn parse(input: ParseStream) -> Result<Self> { 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 - } 6 + enum LocaleKind { 7 + FullPath(Expr), 8 + Prefix(Expr), 19 9 } 20 10 21 11 struct LocaleVariant { 22 12 locale: Ident, 23 - path: Expr, 13 + kind: LocaleKind, 24 14 } 25 15 26 16 impl Parse for LocaleVariant { 27 17 fn parse(input: ParseStream) -> Result<Self> { 28 18 let locale = input.parse::<Ident>()?; 29 19 30 - let content; 31 - syn::parenthesized!(content in input); 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 32 33 - content.parse::<Ident>()?; // "path" 34 - content.parse::<Token![=]>()?; 35 - let path = content.parse::<Expr>()?; 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 + }; 36 47 37 - Ok(LocaleVariant { locale, path }) 48 + Ok(LocaleVariant { locale, kind }) 38 49 } 39 50 } 40 51 41 - struct LocalesArgs { 42 - variants: Punctuated<LocaleVariant, Token![,]>, 52 + struct RouteArgs { 53 + path: Option<Expr>, 54 + locales: Vec<LocaleVariant>, 43 55 } 44 56 45 - impl Parse for LocalesArgs { 57 + impl Parse for RouteArgs { 46 58 fn parse(input: ParseStream) -> Result<Self> { 47 - let variants = Punctuated::parse_terminated(input)?; 48 - Ok(LocalesArgs { variants }) 49 - } 50 - } 59 + let mut path = None; 60 + let mut locales = Vec::new(); 51 61 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); 62 + if input.is_empty() { 63 + return Ok(RouteArgs { path, locales }); 64 + } 57 65 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"); 66 + // Try to parse the first argument 67 + let lookahead = input.lookahead1(); 68 + 69 + // Check if it's "locales(...)" 70 + if lookahead.peek(Ident) { 71 + let ident: Ident = input.fork().parse()?; 72 + if ident == "locales" { 73 + // Parse locales(...) argument 74 + input.parse::<Ident>()?; // consume "locales" 75 + let content; 76 + syn::parenthesized!(content in input); 77 + 78 + let variants = Punctuated::<LocaleVariant, Token![,]>::parse_terminated(&content)?; 79 + locales = variants.into_iter().collect(); 80 + 81 + // Check for duplicate locales 82 + Self::check_duplicate_locales(&locales)?; 83 + 84 + return Ok(RouteArgs { path, locales }); 85 + } 86 + } 87 + 88 + // Otherwise, try to parse as path expression 89 + if !input.is_empty() { 90 + path = Some(input.parse::<Expr>()?); 91 + } 92 + 93 + // Check if there's a comma and more args 94 + if !input.is_empty() { 95 + input.parse::<Token![,]>()?; 96 + 97 + // Check for locales(...) as second argument 98 + if input.peek(Ident) { 99 + let ident: Ident = input.parse()?; 100 + if ident == "locales" { 101 + let content; 102 + syn::parenthesized!(content in input); 103 + 104 + let variants = 105 + Punctuated::<LocaleVariant, Token![,]>::parse_terminated(&content)?; 106 + locales = variants.into_iter().collect(); 107 + 108 + // Check for duplicate locales 109 + Self::check_duplicate_locales(&locales)?; 68 110 } 69 111 } 70 - _ => panic!("locale path must be a string literal"), 71 - }; 72 - locale_data.push_str(&format!("{}={},", locale_name, locale_path)); 112 + } 113 + 114 + Ok(RouteArgs { path, locales }) 73 115 } 116 + } 74 117 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 - }); 118 + impl RouteArgs { 119 + fn check_duplicate_locales(locales: &[LocaleVariant]) -> Result<()> { 120 + use std::collections::HashSet; 121 + let mut seen = HashSet::new(); 80 122 81 - let expanded = quote! { 82 - #modified_struct 83 - }; 123 + for variant in locales { 124 + let locale_name = variant.locale.to_string(); 125 + if !seen.insert(locale_name.clone()) { 126 + return Err(syn::Error::new_spanned( 127 + &variant.locale, 128 + format!("duplicate locale '{}' specified", locale_name), 129 + )); 130 + } 131 + } 84 132 85 - TokenStream::from(expanded) 133 + Ok(()) 134 + } 86 135 } 87 136 88 137 #[proc_macro_attribute] 89 138 pub fn route(attrs: TokenStream, item: TokenStream) -> TokenStream { 90 139 // Parse the input tokens into a syntax tree 91 140 let item_struct = syn::parse_macro_input!(item as ItemStruct); 92 - let attrs = syn::parse_macro_input!(attrs as Args); 141 + let args = syn::parse_macro_input!(attrs as RouteArgs); 93 142 94 143 let struct_name = &item_struct.ident; 95 144 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 - }); 145 + // Generate variants method based on locales 146 + let variant_methods = if !args.locales.is_empty() { 147 + let variant_tuples = args.locales.iter().map(|variant| { 148 + let locale_name = variant.locale.to_string(); 110 149 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(); 115 - 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()) 150 + match &variant.kind { 151 + LocaleKind::FullPath(path) => { 152 + quote! { 153 + (#locale_name.to_string(), #path.to_string()) 154 + } 155 + } 156 + LocaleKind::Prefix(prefix) => { 157 + if args.path.is_none() { 158 + // Emit compile error if prefix is used without base path 159 + quote! { 160 + compile_error!("Cannot use locale prefix without a base route path") 161 + } 162 + } else { 163 + let base_path = args.path.as_ref().unwrap(); 164 + quote! { 165 + (#locale_name.to_string(), format!("{}{}", #prefix, #base_path)) 166 + } 167 + } 168 + } 131 169 } 132 170 }); 133 171 ··· 145 183 }; 146 184 147 185 // Generate route_raw implementation based on whether path is provided 148 - let route_raw_impl = if let Some(path) = &attrs.path { 186 + let route_raw_impl = if let Some(path) = &args.path { 149 187 quote! { 150 188 fn route_raw(&self) -> String { 151 189 #path.to_string()
+1 -1
crates/maudit/src/route.rs
··· 816 816 StyleOptions, 817 817 }; 818 818 pub use crate::content::{ContentContext, ContentEntry, Entry, EntryInner, MarkdownContent}; 819 - pub use maudit_macros::{Params, locales, route}; 819 + pub use maudit_macros::{Params, route}; 820 820 } 821 821 822 822 #[cfg(test)]
+2 -2
examples/i18n/src/main.rs
··· 2 2 mod routes; 3 3 4 4 use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 5 - use routes::{About, Article, Index, Mixed}; 5 + use routes::{About, Article, Contact, Index, Mixed}; 6 6 7 7 fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> { 8 8 coronate( 9 - routes![Index, About, Article, Mixed], 9 + routes![Index, About, Contact, Article, Mixed], 10 10 content_sources![], 11 11 BuildOptions::default(), 12 12 )
+22 -10
examples/i18n/src/routes/about.rs
··· 2 2 use maud::html; 3 3 use maudit::route::prelude::*; 4 4 5 - #[locales( 6 - en(path = "/en/about"), 7 - sv(path = "/sv/om-oss"), 8 - de(path = "/de/uber-uns") 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 + ) 9 16 )] 10 - #[route("/about")] 11 17 pub struct About; 12 18 13 19 impl Route for About { 14 20 fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 15 21 layout(html! { 16 22 h1 { "About" } 17 - p { "This route has both a base path and localized variants." } 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 + } 18 29 nav { 30 + h3 { "Generated routes:" } 19 31 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" } } 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)" } } 24 36 } 25 37 } 26 38 })
+4 -2
examples/i18n/src/routes/articles.rs
··· 7 7 pub slug: String, 8 8 } 9 9 10 - #[locales(en(path = "/en/articles/[slug]"), sv(path = "/sv/artiklar/[slug]"))] 11 - #[route("/articles/[slug]")] 10 + #[route( 11 + "/articles/[slug]", 12 + locales(en = "/en/articles/[slug]", sv = "/sv/artiklar/[slug]") 13 + )] 12 14 pub struct Article; 13 15 14 16 impl Route<ArticleParams> for Article {
+28
examples/i18n/src/routes/contact.rs
··· 1 + use crate::layout::layout; 2 + use maud::html; 3 + use maudit::route::prelude::*; 4 + 5 + #[route( 6 + "/contact", 7 + locales(en(prefix = "/en"), sv(prefix = "/sv"), de(path = "/de/kontakt")) 8 + )] 9 + pub struct Contact; 10 + 11 + impl Route for Contact { 12 + fn render(&self, _ctx: &mut PageContext) -> impl Into<RenderResult> { 13 + layout(html! { 14 + h1 { "Contact" } 15 + p { "This route demonstrates different locale syntaxes:" } 16 + p { "en uses prefix syntax, sv uses prefix syntax, de uses path syntax" } 17 + p { "Results: /en/contact, /sv/contact, /de/kontakt" } 18 + nav { 19 + ul { 20 + li { a href="/contact" { "Default" } } 21 + li { a href="/en/contact" { "English" } } 22 + li { a href="/sv/contact" { "Swedish" } } 23 + li { a href="/de/kontakt" { "German" } } 24 + } 25 + } 26 + }) 27 + } 28 + }
+1 -2
examples/i18n/src/routes/index.rs
··· 2 2 use maud::html; 3 3 use maudit::route::prelude::*; 4 4 5 - #[locales(en(path = "/en"), sv(path = "/sv"), de(path = "/de"))] 6 - #[route] 5 + #[route(locales(en = "/en", sv = "/sv", de = "/de"))] 7 6 pub struct Index; 8 7 9 8 impl Route for Index {
+1 -2
examples/i18n/src/routes/mixed.rs
··· 9 9 10 10 // Base route is static (/products) 11 11 // But variants have dynamic parameters (/en/products/[id]) 12 - #[locales(en(path = "/en/products/[id]"), sv(path = "/sv/produkter/[id]"))] 13 - #[route] 12 + #[route(locales(en = "/en/products/[id]", sv = "/sv/produkter/[id]"))] 14 13 pub struct Mixed; 15 14 16 15 impl Route<MixedParams> for Mixed {
+2
examples/i18n/src/routes/mod.rs
··· 1 1 mod about; 2 2 mod articles; 3 + mod contact; 3 4 mod index; 4 5 mod mixed; 5 6 6 7 pub use about::About; 7 8 pub use articles::Article; 9 + pub use contact::Contact; 8 10 pub use index::Index; 9 11 pub use mixed::Mixed;