leptos_next_metadata_macros/
lib.rs

1//! Procedural macros for leptos-next-metadata
2
3use proc_macro::TokenStream;
4use quote::quote;
5use syn::{parse::Parse, parse::ParseStream, parse_macro_input, Expr, Ident, Result, Token};
6
7/// Generate metadata tags for Leptos applications
8///
9/// This macro generates the appropriate leptos_meta components based on the provided metadata.
10///
11/// # Examples
12///
13/// ```rust
14/// use leptos_next_metadata_macros::metadata;
15///
16/// metadata! {
17///     title: "My Page",
18///     description: "This is my page description",
19///     keywords: ["rust", "leptos", "web"],
20///     openGraph: {
21///         title: "My Page",
22///         type: "website",
23///         url: "https://example.com"
24///     },
25///     twitter: {
26///         card: "summary",
27///         title: "My Page"
28///     }
29/// }
30/// ```
31#[proc_macro]
32pub fn metadata(input: TokenStream) -> TokenStream {
33    let input = parse_macro_input!(input as MetadataInput);
34
35    match generate_metadata_code(input) {
36        Ok(tokens) => tokens.into(),
37        Err(e) => e.to_compile_error().into(),
38    }
39}
40
41/// Generate dynamic metadata at runtime
42///
43/// This macro accepts an async closure that returns a Metadata struct and generates
44/// the appropriate leptos_meta components reactively.
45///
46/// # Examples
47///
48/// ```rust
49/// use leptos_next_metadata_macros::generate_metadata;
50///
51/// generate_metadata! {
52///     async || {
53///         // Fetch data from API
54///         let data = fetch_page_data().await;
55///
56///         Metadata {
57///             title: Title::Template {
58///                 template: "%s | My Blog".into(),
59///                 default: "My Blog".into(),
60///             },
61///             description: Some(data.excerpt),
62///             ..Default::default()
63///         }
64///     }
65/// }
66/// ```
67#[proc_macro]
68pub fn generate_metadata(input: TokenStream) -> TokenStream {
69    let parsed = parse_macro_input!(input as GenerateMetadataInputParser);
70    let input = GenerateMetadataInput {
71        closure: parsed.closure,
72    };
73
74    generate_dynamic_metadata_code(input).into()
75}
76
77/// Parsed input for the generate_metadata macro
78struct GenerateMetadataInput {
79    /// The async closure that generates metadata
80    closure: Expr,
81}
82
83/// Parser for the generate_metadata macro input
84struct GenerateMetadataInputParser {
85    closure: Expr,
86}
87
88impl Parse for GenerateMetadataInputParser {
89    fn parse(input: ParseStream) -> Result<Self> {
90        // Parse the async closure: async || { ... }
91        let closure: Expr = input.parse()?;
92
93        Ok(GenerateMetadataInputParser { closure })
94    }
95}
96
97/// Generate the dynamic metadata code
98fn generate_dynamic_metadata_code(input: GenerateMetadataInput) -> proc_macro2::TokenStream {
99    let closure = &input.closure;
100
101    quote! {
102        {
103            use leptos::*;
104            use leptos::prelude::*;
105            use leptos_meta::*;
106            use leptos_next_metadata::metadata::Metadata;
107
108            // Create a signal to hold the metadata
109            let (metadata, set_metadata) = signal(Metadata::default());
110
111            // Execute the async closure and update the metadata
112            leptos::task::spawn_local(async move {
113                let result = (#closure)().await;
114                set_metadata.set(result);
115            });
116
117            // Generate metadata components reactively
118            move || {
119                let meta = metadata.get();
120
121                // Basic metadata
122                let title_view = meta.title.as_ref().map(|title| {
123                    match title {
124                        leptos_next_metadata::metadata::Title::Static(s) => {
125                            view! { <Title text=s.to_string()/> }
126                        },
127                        leptos_next_metadata::metadata::Title::Template { template: _, default } => {
128                            view! { <Title text=default.to_string()/> }
129                        }
130                    }
131                });
132
133                let description_view = meta.description.as_ref().map(|desc| {
134                    view! { <Meta name="description" content=desc.to_string()/> }
135                });
136
137                let keywords_view = meta.keywords.as_ref().map(|keywords| {
138                    match keywords {
139                        leptos_next_metadata::metadata::Keywords::Single(k) => {
140                            view! { <Meta name="keywords" content=k.to_string()/> }
141                        },
142                        leptos_next_metadata::metadata::Keywords::Multiple(ks) => {
143                            let keywords_str = ks.join(", ");
144                            view! { <Meta name="keywords" content=keywords_str/> }
145                        }
146                    }
147                });
148
149                let canonical_view = meta.canonical.as_ref().map(|url| {
150                    view! { <Link rel="canonical" href=url.to_string()/> }
151                });
152
153                // OpenGraph metadata
154                let og_view = meta.open_graph.as_ref().map(|og| {
155                    view! {
156                        <>
157                            {og.title.as_ref().map(|title| {
158                                view! { <Meta property="og:title" content=title.to_string()/> }
159                            })}
160
161                            {og.description.as_ref().map(|desc| {
162                                view! { <Meta property="og:description" content=desc.to_string()/> }
163                            })}
164
165                            {og.r#type.as_ref().map(|og_type| {
166                                view! { <Meta property="og:type" content=og_type.to_string()/> }
167                            })}
168
169                            {og.url.as_ref().map(|url| {
170                                view! { <Meta property="og:url" content=url.to_string()/> }
171                            })}
172
173                            {og.site_name.as_ref().map(|name| {
174                                view! { <Meta property="og:site_name" content=name.to_string()/> }
175                            })}
176
177                            // OG images iteration removed for now - will add back later
178                        </>
179                    }
180                });
181
182                // Twitter metadata
183                let twitter_view = meta.twitter.as_ref().map(|twitter| {
184                    view! {
185                        <>
186                            {twitter.card.as_ref().map(|card| {
187                                let card_str = match card {
188                                    leptos_next_metadata::metadata::TwitterCard::Summary => "summary",
189                                    leptos_next_metadata::metadata::TwitterCard::SummaryLargeImage => "summary_large_image",
190                                    leptos_next_metadata::metadata::TwitterCard::App => "app",
191                                    leptos_next_metadata::metadata::TwitterCard::Player => "player",
192                                };
193                                view! { <Meta name="twitter:card" content=card_str.to_string()/> }
194                            })}
195
196                            {twitter.title.as_ref().map(|title| {
197                                view! { <Meta name="twitter:title" content=title.to_string()/> }
198                            })}
199
200                            {twitter.description.as_ref().map(|desc| {
201                                view! { <Meta name="twitter:description" content=desc.to_string()/> }
202                            })}
203                        </>
204                    }
205                });
206
207                // Additional metadata iteration removed for now - will add back later
208
209                // Return all views
210                view! {
211                    <>
212                        {title_view.map(|view| view)}
213                        {description_view.map(|view| view)}
214                        {keywords_view.map(|view| view)}
215                        {canonical_view.map(|view| view)}
216                        {og_view.map(|view| view)}
217                        {twitter_view.map(|view| view)}
218                    </>
219                }
220            }
221        }
222    }
223}
224
225/// Metadata input parser that handles nested structures
226struct MetadataInput {
227    fields: Vec<MetadataField>,
228}
229
230struct MetadataField {
231    name: Ident,
232    value: MetadataValue,
233}
234
235#[allow(clippy::large_enum_variant)]
236enum MetadataValue {
237    /// Simple string or expression
238    Simple(Expr),
239    /// Nested struct-like object
240    Nested(Vec<MetadataField>),
241    /// Array of values
242    Array(Vec<MetadataValue>),
243}
244
245impl Parse for MetadataInput {
246    fn parse(input: ParseStream) -> Result<Self> {
247        let mut fields = Vec::new();
248
249        while !input.is_empty() {
250            let name: Ident = input.parse()?;
251            input.parse::<Token![:]>()?;
252            let value = MetadataValue::parse(input)?;
253
254            fields.push(MetadataField { name, value });
255
256            // Handle optional comma
257            if input.peek(Token![,]) {
258                input.parse::<Token![,]>()?;
259            }
260        }
261
262        Ok(MetadataInput { fields })
263    }
264}
265
266impl Parse for MetadataValue {
267    fn parse(input: ParseStream) -> Result<Self> {
268        if input.peek(syn::token::Brace) {
269            // Parse nested object: { field: value, ... }
270            let content;
271            syn::braced!(content in input);
272
273            let mut fields = Vec::new();
274            while !content.is_empty() {
275                let name: Ident = content.parse()?;
276                content.parse::<Token![:]>()?;
277                let value = MetadataValue::parse(&content)?;
278
279                fields.push(MetadataField { name, value });
280
281                if content.peek(Token![,]) {
282                    content.parse::<Token![,]>()?;
283                }
284            }
285
286            Ok(MetadataValue::Nested(fields))
287        } else if input.peek(syn::token::Bracket) {
288            // Parse array: [value1, value2, ...]
289            let content;
290            syn::bracketed!(content in input);
291
292            let mut values = Vec::new();
293            while !content.is_empty() {
294                let value = MetadataValue::parse(&content)?;
295                values.push(value);
296
297                if content.peek(Token![,]) {
298                    content.parse::<Token![,]>()?;
299                }
300            }
301
302            Ok(MetadataValue::Array(values))
303        } else {
304            // Parse simple expression
305            let expr: Expr = input.parse()?;
306            Ok(MetadataValue::Simple(expr))
307        }
308    }
309}
310
311fn generate_metadata_code(input: MetadataInput) -> Result<proc_macro2::TokenStream> {
312    let mut meta_tags = Vec::new();
313
314    for field in input.fields {
315        let field_name = field.name.to_string();
316        let tags = generate_field_meta_tags(&field_name, &field.value)?;
317        meta_tags.extend(tags);
318    }
319
320    Ok(quote! {
321        {
322            use leptos_meta::*;
323
324            view! {
325                <>
326                    #(#meta_tags)*
327                </>
328            }
329        }
330    })
331}
332
333fn generate_field_meta_tags(
334    field_name: &str,
335    value: &MetadataValue,
336) -> Result<Vec<proc_macro2::TokenStream>> {
337    let mut tags = Vec::new();
338
339    match value {
340        MetadataValue::Simple(expr) => {
341            let tag = match field_name {
342                "title" => quote! {
343                    <Title text=#expr/>
344                },
345                "description" => quote! {
346                    <Meta name="description" content=#expr/>
347                },
348                "keywords" => quote! {
349                    <Meta name="keywords" content=#expr/>
350                },
351                "author" => quote! {
352                    <Meta name="author" content=#expr/>
353                },
354                "robots" => quote! {
355                    <Meta name="robots" content=#expr/>
356                },
357                "canonical" => quote! {
358                    <Link rel="canonical" href=#expr/>
359                },
360                "viewport" => quote! {
361                    <Meta name="viewport" content=#expr/>
362                },
363                "themeColor" => quote! {
364                    <Meta name="theme-color" content=#expr/>
365                },
366                "colorScheme" => quote! {
367                    <Meta name="color-scheme" content=#expr/>
368                },
369                "referrer" => quote! {
370                    <Meta name="referrer" content=#expr/>
371                },
372                "formatDetection" => quote! {
373                    <Meta name="format-detection" content=#expr/>
374                },
375                _ => quote! {
376                    <Meta name=#field_name content=#expr/>
377                },
378            };
379            tags.push(tag);
380        }
381        MetadataValue::Nested(fields) => {
382            match field_name {
383                "openGraph" | "open_graph" => {
384                    for field in fields {
385                        let field_name = field.name.to_string();
386                        let nested_tags = generate_og_meta_tags(&field_name, &field.value)?;
387                        tags.extend(nested_tags);
388                    }
389                }
390                "twitter" => {
391                    for field in fields {
392                        let field_name = field.name.to_string();
393                        let nested_tags = generate_twitter_meta_tags(&field_name, &field.value)?;
394                        tags.extend(nested_tags);
395                    }
396                }
397                _ => {
398                    // Handle other nested structures generically
399                    for field in fields {
400                        let field_name = field.name.to_string();
401                        if let MetadataValue::Simple(value) = &field.value {
402                            tags.push(quote! {
403                                <Meta name=format!("{}:{}", #field_name, #field_name) content=#value/>
404                            });
405                        }
406                    }
407                }
408            }
409        }
410        MetadataValue::Array(values) => {
411            let array_tags = generate_array_meta_tags(field_name, values)?;
412            tags.extend(array_tags);
413        }
414    }
415
416    Ok(tags)
417}
418
419fn generate_og_meta_tags(
420    field_name: &str,
421    value: &MetadataValue,
422) -> Result<Vec<proc_macro2::TokenStream>> {
423    let mut tags = Vec::new();
424
425    match value {
426        MetadataValue::Simple(expr) => {
427            let tag = match field_name {
428                "title" => quote! {
429                    <Meta property="og:title" content=#expr/>
430                },
431                "description" => quote! {
432                    <Meta property="og:description" content=#expr/>
433                },
434                "type" => quote! {
435                    <Meta property="og:type" content=#expr/>
436                },
437                "url" => quote! {
438                    <Meta property="og:url" content=#expr/>
439                },
440                "siteName" | "site_name" => quote! {
441                    <Meta property="og:site_name" content=#expr/>
442                },
443                "locale" => quote! {
444                    <Meta property="og:locale" content=#expr/>
445                },
446                _ => quote! {
447                    <Meta property=format!("og:{}", #field_name) content=#expr/>
448                },
449            };
450            tags.push(tag);
451        }
452        MetadataValue::Array(array_values) => {
453            match field_name {
454                "images" => {
455                    for image_value in array_values {
456                        if let MetadataValue::Simple(url) = image_value {
457                            tags.push(quote! {
458                                <Meta property="og:image" content=#url/>
459                            });
460                        }
461                    }
462                }
463                "videos" => {
464                    for video_value in array_values {
465                        if let MetadataValue::Simple(url) = video_value {
466                            tags.push(quote! {
467                                <Meta property="og:video" content=#url/>
468                            });
469                        }
470                    }
471                }
472                _ => {
473                    // TODO: Handle generic array field for og:{} - need to implement proper array handling
474                }
475            }
476        }
477        _ => {
478            // TODO: Handle other nested structures - need to implement proper nested handling
479        }
480    }
481
482    Ok(tags)
483}
484
485fn generate_twitter_meta_tags(
486    field_name: &str,
487    value: &MetadataValue,
488) -> Result<Vec<proc_macro2::TokenStream>> {
489    let mut tags = Vec::new();
490
491    match value {
492        MetadataValue::Simple(expr) => {
493            let tag = match field_name {
494                "card" => quote! {
495                    <Meta name="twitter:card" content=#expr/>
496                },
497                "site" => quote! {
498                    <Meta name="twitter:site" content=#expr/>
499                },
500                "creator" => quote! {
501                    <Meta name="twitter:creator" content=#expr/>
502                },
503                "title" => quote! {
504                    <Meta name="twitter:title" content=#expr/>
505                },
506                "description" => quote! {
507                    <Meta name="twitter:description" content=#expr/>
508                },
509                "image" => quote! {
510                    <Meta name="twitter:image" content=#expr/>
511                },
512                _ => quote! {
513                    <Meta name=format!("twitter:{}", #field_name) content=#expr/>
514                },
515            };
516            tags.push(tag);
517        }
518        MetadataValue::Array(array_values) => {
519            match field_name {
520                "images" => {
521                    for image_value in array_values {
522                        if let MetadataValue::Simple(url) = image_value {
523                            tags.push(quote! {
524                                <Meta name="twitter:image" content=#url/>
525                            });
526                        }
527                    }
528                }
529                _ => {
530                    // TODO: Handle generic twitter array fields
531                }
532            }
533        }
534        _ => {
535            // TODO: Handle other nested structures for twitter
536        }
537    }
538
539    Ok(tags)
540}
541
542fn generate_array_meta_tags(
543    field_name: &str,
544    values: &[MetadataValue],
545) -> Result<Vec<proc_macro2::TokenStream>> {
546    let mut tags = Vec::new();
547
548    match field_name {
549        "keywords" => {
550            // Convert array to comma-separated string
551            let mut keyword_exprs = Vec::new();
552            for value in values {
553                if let MetadataValue::Simple(expr) = value {
554                    keyword_exprs.push(expr);
555                }
556            }
557
558            if !keyword_exprs.is_empty() {
559                tags.push(quote! {
560                    <Meta name="keywords" content={[#(#keyword_exprs),*].join(", ")}/>
561                });
562            }
563        }
564        "images" => {
565            // Generate image meta tags
566            for value in values {
567                if let MetadataValue::Simple(url) = value {
568                    tags.push(quote! {
569                        <Meta name="image" content=#url/>
570                    });
571                }
572            }
573        }
574        "authors" => {
575            // Generate author meta tags
576            for value in values {
577                if let MetadataValue::Simple(author) = value {
578                    tags.push(quote! {
579                        <Meta name="author" content=#author/>
580                    });
581                }
582            }
583        }
584        _ => {
585            // Handle other arrays generically
586            for value in values {
587                if let MetadataValue::Simple(expr) = value {
588                    tags.push(quote! {
589                        <Meta name=#field_name content=#expr/>
590                    });
591                }
592            }
593        }
594    }
595
596    Ok(tags)
597}