storybook_derive/
lib.rs

1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{parse_macro_input, DeriveInput, Data, Fields};
4
5// Helper to extract dominator_crate attribute from the struct
6// Returns the crate path as a string, defaults to "dominator"
7fn get_dominator_crate_attr(input: &DeriveInput) -> String {
8    for attr in &input.attrs {
9        if attr.path().is_ident("dominator_crate") {
10            if let Ok(syn::Expr::Lit(syn::ExprLit {
11                lit: syn::Lit::Str(lit_str),
12                ..
13            })) = attr.parse_args::<syn::Expr>()
14            {
15                return lit_str.value();
16            }
17        }
18    }
19    "dominator".to_string()
20}
21
22// Helper to extract story attributes from a field
23// Returns: (control_type, default_value, from_type, lorem_word_count, skip)
24fn get_story_attrs(field: &syn::Field) -> (Option<String>, Option<String>, Option<syn::Type>, Option<usize>, bool) {
25    let mut control_type = None;
26    let mut default_value = None;
27    let mut from_type = None;
28    let mut lorem_count = None;
29    let mut skip = false;
30
31    for attr in &field.attrs {
32        if attr.path().is_ident("story") {
33            // Try parsing as a list of name-value pairs
34            let _ = attr.parse_nested_meta(|meta| {
35                if meta.path.is_ident("control") {
36                    if let Ok(value) = meta.value() {
37                        if let Ok(lit_str) = value.parse::<syn::LitStr>() {
38                            control_type = Some(lit_str.value());
39                        }
40                    }
41                } else if meta.path.is_ident("default") {
42                    if let Ok(value) = meta.value() {
43                        if let Ok(lit_str) = value.parse::<syn::LitStr>() {
44                            default_value = Some(lit_str.value());
45                        }
46                    }
47                } else if meta.path.is_ident("from") {
48                    if let Ok(value) = meta.value() {
49                        if let Ok(lit_str) = value.parse::<syn::LitStr>() {
50                            from_type =
51                                Some(syn::parse_str(&lit_str.value()).expect("Invalid type for from"));
52                        }
53                    }
54                } else if meta.path.is_ident("lorem") {
55                    // Handle both `#[story(lorem)]` (defaults to 8) and `#[story(lorem = "N")]`
56                    if let Ok(value) = meta.value() {
57                        if let Ok(lit_str) = value.parse::<syn::LitStr>() {
58                            if let Ok(count) = lit_str.value().parse::<usize>() {
59                                lorem_count = Some(count);
60                            }
61                        }
62                    } else {
63                        // No value specified, use default of 8
64                        lorem_count = Some(8);
65                    }
66                } else if meta.path.is_ident("skip") {
67                    skip = true;
68                }
69                Ok(())
70            });
71        }
72    }
73
74    (control_type, default_value, from_type, lorem_count, skip)
75}
76
77// Generate lorem ipsum text with specified number of words
78fn generate_lorem_ipsum(word_count: usize) -> String {
79    const LOREM_WORDS: &[&str] = &[
80        "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
81        "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et",
82        "dolore", "magna", "aliqua", "enim", "ad", "minim", "veniam", "quis",
83        "nostrud", "exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea",
84        "commodo", "consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate",
85        "velit", "esse", "cillum", "fugiat", "nulla", "pariatur", "excepteur", "sint",
86        "occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui", "officia",
87        "deserunt", "mollit", "anim", "id", "est", "laborum", "pellentesque", "habitant",
88        "morbi", "tristique", "senectus", "netus", "et", "malesuada", "fames", "ac",
89        "turpis", "egestas", "vestibulum", "tortor", "quam", "feugiat", "vitae", "ultricies",
90        "legimus", "typi", "qui", "nusquam", "vici", "sunt", "signa", "consuetudium"
91    ];
92    
93    let mut words = Vec::new();
94    for i in 0..word_count {
95        words.push(LOREM_WORDS[i % LOREM_WORDS.len()]);
96    }
97    words.join(" ")
98}
99
100
101fn generate_storybook_js(name: &str, _fields: &syn::punctuated::Punctuated<syn::Field, syn::token::Comma>, arg_types: &[(String, String, String, String, String)]) {
102    // Generate argTypes from fields
103    let arg_types_json: Vec<String> = arg_types.iter().map(|(field_name, control, _default_val, required, options_json)| {
104        let options_str = if !options_json.is_empty() {
105            format!(", options: {}", options_json)
106        } else {
107            String::new()
108        };
109        
110        let required_str = if required == "true" {
111            ", table: { category: 'required' }"
112        } else {
113            ""
114        };
115        
116        format!(
117            "    {}: {{\n      control: '{}',\n      description: '{}'{}{}\n    }}",
118            field_name, control, field_name, options_str, required_str
119        )
120    }).collect();
121    
122    let args_str = arg_types_json.join(",\n");
123    
124    // Generate default args
125    let default_args: Vec<String> = arg_types.iter().map(|(field_name, _, default_val, _, _)| {
126        format!("  {}: {}", field_name, default_val)
127    }).collect();
128    
129    let default_args_str = default_args.join(",\n");
130    
131    let js_content = format!(r#"import init, {{ register_all_stories, render_story, get_enum_options, init_enums }} from '../../example/pkg/example.js';
132
133// Initialize WASM
134await init();
135
136console.log('About to call init_enums...');
137init_enums();
138console.log('init_enums called');
139
140register_all_stories();
141
142// Define the story with populated enum options
143export default {{
144  title: 'Components/{}',
145  argTypes: {{
146{}
147  }},
148}};
149
150const Template = (args) => {{
151  const container = document.createElement('div');
152  const dom = render_story('{}', args);
153  container.appendChild(dom);
154  return container;
155}};
156
157export const Default = Template.bind({{}});
158Default.args = {{
159{}
160}};
161"#, name, args_str, name, default_args_str);
162
163    // Write to storybook/stories directory
164    let output_dir = std::env::var("CARGO_MANIFEST_DIR")
165        .map(|d| std::path::PathBuf::from(d).parent().unwrap().join("storybook/stories"))
166        .unwrap_or_else(|_| std::path::PathBuf::from("storybook/stories"));
167    
168    if let Err(_) = std::fs::create_dir_all(&output_dir) {
169        // Directory might already exist, that's fine
170    }
171    
172    let output_file = output_dir.join(format!("{}.stories.js", name));
173    let _ = std::fs::write(output_file, js_content);
174}
175
176/// Attribute macro to document the dominator crate path being used.
177/// 
178/// This is a documentation/metadata attribute that doesn't affect generated code,
179/// but makes it clear which dominator path is being used when it's vendored or re-exported.
180/// 
181/// Usage on a module:
182/// ```ignore
183/// #[storybook::set_dominator_path("crate::vendored::dominator")]
184/// mod stories;
185/// ```
186/// 
187/// This allows specifying a custom path to the dominator crate when it's been vendored
188/// or re-exported from a different location.
189#[proc_macro_attribute]
190pub fn set_dominator_path(_args: TokenStream, input: TokenStream) -> TokenStream {
191    // This is just a marker attribute, it doesn't modify the input
192    // It's used for documentation purposes to indicate which dominator path is in use
193    input
194}
195
196#[proc_macro_derive(Story, attributes(story, dominator_crate))]
197pub fn derive_story(input: TokenStream) -> TokenStream {
198    let input = parse_macro_input!(input as DeriveInput);
199    let _dominator_crate = get_dominator_crate_attr(&input);
200    let name = &input.ident;
201    let generics = &input.generics;
202    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
203    let name_str = name.to_string();
204    let story_args_name = syn::Ident::new(&format!("{}StoryArgs", name), name.span());
205
206    // Extract field information
207    let fields = match &input.data {
208        Data::Struct(data) => match &data.fields {
209            Fields::Named(fields) => &fields.named,
210            _ => panic!("Story can only be derived for structs with named fields"),
211        },
212        _ => panic!("Story can only be derived for structs"),
213    };
214
215    let story_args_fields = fields.iter().filter_map(|field| {
216        let field_name = &field.ident;
217        let field_ty = &field.ty;
218        let (control_type, _, from_type, _, skip) = get_story_attrs(field);
219        
220        // Skip fields marked with #[story(skip)]
221        if skip {
222            return None;
223        }
224        
225        // Make select control fields optional so they can deserialize from undefined
226        let should_be_optional = control_type.as_ref().map(|c| c == "select").unwrap_or(false);
227
228        let field_def = if let Some(from_type) = from_type {
229            if should_be_optional {
230                quote! {
231                    #[serde(default)]
232                    pub #field_name: Option<#from_type>
233                }
234            } else {
235                quote! {
236                    #[serde(default)]
237                    pub #field_name: #from_type
238                }
239            }
240        } else {
241            if should_be_optional {
242                quote! {
243                    #[serde(default)]
244                    pub #field_name: Option<#field_ty>
245                }
246            } else {
247                quote! {
248                    #[serde(default)]
249                    pub #field_name: #field_ty
250                }
251            }
252        };
253        
254        Some(field_def)
255    });
256
257    let from_impl_fields = fields.iter().map(|field| {
258        let field_name = &field.ident;
259        let (control_type, _, _, _, skip) = get_story_attrs(field);
260        
261        if skip {
262            // For skipped fields, use Default::default()
263            return quote! { #field_name: Default::default() };
264        }
265        
266        let should_be_optional = control_type.as_ref().map(|c| c == "select").unwrap_or(false);
267        
268        if should_be_optional {
269            // For optional enum fields, unwrap_or_default() or just use the option as-is
270            quote! { #field_name: value.#field_name.unwrap_or_default() }
271        } else {
272            quote! { #field_name: value.#field_name.into() }
273        }
274    });
275
276    // Generate arg type information for each field
277    let mut arg_types_for_js: Vec<(String, String, String, String, String)> = Vec::new();
278    let mut arg_types_vec = Vec::new();
279    
280    for field in fields.iter() {
281        let field_name = &field.ident;
282        let field_name_str = field_name.as_ref().unwrap().to_string();
283        let field_ty = &field.ty;
284        let ty_string = quote!(#field_ty).to_string();
285        let is_option = ty_string.starts_with("Option <");
286
287        let (control_type, default_value, from_type, lorem_count, skip) = get_story_attrs(field);
288        
289        // Skip fields marked with #[story(skip)]
290        if skip {
291            continue;
292        }
293
294        let mut options = quote! { None };
295        let mut options_json = String::new();
296        let control = if let Some(ref control_type) = control_type {
297            match control_type.as_str() {
298                "color" => quote! { storybook::ControlType::Color },
299                "select" => {
300                    options = quote! { Some(<#field_ty as storybook::StorySelect>::options()) };
301                    // Extract the enum type name from the field type
302                    let enum_type_name = ty_string.trim().replace(" ", "");
303                    options_json = format!("get_enum_options('{}')", enum_type_name);
304                    quote! { storybook::ControlType::Select }
305                }
306                _ => quote! { storybook::ControlType::Text },
307            }
308        } else {
309            let ty_to_check = if let Some(from_type) = &from_type {
310                quote!(#from_type).to_string()
311            } else {
312                ty_string.clone()
313            };
314
315            if ty_to_check.contains("bool") {
316                quote! { storybook::ControlType::Boolean }
317            } else if ty_to_check.contains("i32")
318                || ty_to_check.contains("f32")
319                || ty_to_check.contains("u32")
320                || ty_to_check.contains("f64")
321                || ty_to_check.contains("usize")
322            {
323                quote! { storybook::ControlType::Number }
324            } else {
325                quote! { storybook::ControlType::Text }
326            }
327        };
328
329        let default_value_quoted = match &default_value {
330            Some(v) => quote! { Some(#v.to_string()) },
331            None => {
332                if let Some(lorem_word_count) = lorem_count {
333                    let lorem_text = generate_lorem_ipsum(lorem_word_count);
334                    quote! { Some(#lorem_text.to_string()) }
335                } else {
336                    quote! { None }
337                }
338            }
339        };
340        
341        let control_str = match control_type.as_ref() {
342            Some(ct) => {
343                match ct.as_str() {
344                    "color" => "color".to_string(),
345                    "select" => "select".to_string(),
346                    _ => "text".to_string(),
347                }
348            }
349            None => {
350                if ty_string.contains("bool") {
351                    "boolean".to_string()
352                } else if ty_string.contains("i32") || ty_string.contains("f32") || ty_string.contains("u32") || ty_string.contains("f64") || ty_string.contains("usize") {
353                    "number".to_string()
354                } else {
355                    "text".to_string()
356                }
357            }
358        };
359        
360        let default_val_str = match &default_value {
361            Some(dv) => dv.clone(),
362            None => {
363                if let Some(lorem_word_count) = lorem_count {
364                    // Generate lorem ipsum text
365                    format!("'{}'", generate_lorem_ipsum(lorem_word_count))
366                } else if control_str == "select" {
367                    "null".to_string()
368                } else if ty_string.contains("String") {
369                    "''".to_string()
370                } else if ty_string.contains("bool") {
371                    "false".to_string()
372                } else if ty_string.contains("i32") || ty_string.contains("f32") || ty_string.contains("u32") || ty_string.contains("f64") || ty_string.contains("usize") {
373                    "0".to_string()
374                } else {
375                    "undefined".to_string()
376                }
377            }
378        };
379        
380        arg_types_for_js.push((
381            field_name_str.clone(),
382            control_str,
383            default_val_str,
384            if is_option { "false" } else { "true" }.to_string(),
385            options_json,
386        ));
387
388        arg_types_vec.push(quote! {
389            storybook::ArgType {
390                name: #field_name_str.to_string(),
391                default_value: #default_value_quoted,
392                control: #control,
393                required: !#is_option,
394                options: #options,
395            }
396        });
397    }
398
399    // Generate the Storybook JavaScript file
400    generate_storybook_js(&name_str, fields, &arg_types_for_js);
401
402    // Generate helper methods
403    let expanded = quote! {
404        #[derive(serde::Deserialize, Default)]
405        pub struct #story_args_name {
406            #(#story_args_fields),*
407        }
408
409        impl From<#story_args_name> for #name {
410            fn from(value: #story_args_name) -> Self {
411                Self {
412                    #(#from_impl_fields),*
413                }
414            }
415        }
416
417        impl #impl_generics storybook::StoryMeta for #name #ty_generics #where_clause {
418            type StoryArgs = #story_args_name;
419
420            fn name() -> &'static str {
421                #name_str
422            }
423
424            fn args() -> Vec<storybook::ArgType> {
425                vec![
426                    #(#arg_types_vec),*
427                ]
428            }
429        }
430    };
431
432    TokenStream::from(expanded)
433}
434
435/// Derive macro for StorySelect trait
436/// 
437/// This macro generates select control options from an enum.
438/// Each variant becomes an option in a select dropdown in Storybook.
439/// Also implements FromStr for deserializing from Storybook values.
440#[proc_macro_derive(StorySelect, attributes(story_select))]
441pub fn derive_story_select(input: TokenStream) -> TokenStream {
442    let input = parse_macro_input!(input as DeriveInput);
443    let name = &input.ident;
444    let generics = &input.generics;
445    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
446
447    // Extract variant information
448    let variants = match &input.data {
449        Data::Enum(data) => &data.variants,
450        _ => panic!("StorySelect can only be derived for enums"),
451    };
452
453    // Generate option values from enum variants
454    let options = variants.iter().map(|variant| {
455        let variant_name = &variant.ident;
456        let variant_str = variant_name.to_string();
457        
458        quote! {
459            #variant_str.to_string()
460        }
461    });
462
463    // Generate FromStr match arms
464    let from_str_arms = variants.iter().map(|variant| {
465        let variant_name = &variant.ident;
466        let variant_str = variant_name.to_string();
467        
468        quote! {
469            #variant_str => Ok(#name::#variant_name)
470        }
471    });
472
473    // Generate Display match arms
474    let display_arms = variants.iter().map(|variant| {
475        let variant_name = &variant.ident;
476        let variant_str = variant_name.to_string();
477        
478        quote! {
479            #name::#variant_name => #variant_str
480        }
481    });
482
483    let name_str = name.to_string();
484
485    // Generate implementation
486    let expanded = quote! {
487        impl #impl_generics storybook::StorySelect for #name #ty_generics #where_clause {
488            fn type_name() -> &'static str {
489                #name_str
490            }
491
492            fn options() -> Vec<String> {
493                vec![
494                    #(#options),*
495                ]
496            }
497        }
498
499        // Auto-register enum options on first use
500        impl #impl_generics #name #ty_generics #where_clause {
501            #[doc(hidden)]
502            pub fn __register_enum_options() {
503                storybook::register_enum_options(
504                    #name_str,
505                    <#name as storybook::StorySelect>::options()
506                );
507            }
508        }
509
510        impl #impl_generics std::str::FromStr for #name #ty_generics #where_clause {
511            type Err = String;
512
513            fn from_str(s: &str) -> Result<Self, Self::Err> {
514                match s {
515                    #(#from_str_arms,)*
516                    _ => Err(format!("Invalid {} variant: {}", #name_str, s))
517                }
518            }
519        }
520
521        impl #impl_generics std::fmt::Display for #name #ty_generics #where_clause {
522            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
523                let s = match self {
524                    #(#display_arms,)*
525                };
526                write!(f, "{}", s)
527            }
528        }
529    };
530
531    TokenStream::from(expanded)
532}
533
534/// Macro to generate a registration function for all stories
535/// Usage: register_stories!(Button, Card, Input);
536#[proc_macro]
537pub fn register_stories(input: TokenStream) -> TokenStream {
538    let types = syn::parse_macro_input!(input with syn::punctuated::Punctuated::<syn::Type, syn::Token![,]>::parse_terminated);
539    
540    let registrations = types.iter().map(|ty| {
541        quote! {
542            storybook::register_story::<#ty>();
543        }
544    });
545    
546    let expanded = quote! {
547        #[wasm_bindgen::prelude::wasm_bindgen]
548        pub fn register_all_stories() {
549            #(#registrations)*
550        }
551    };
552    
553    TokenStream::from(expanded)
554}
555
556/// Macro to generate a registration function for all enums
557/// Usage: register_enums!(AlertType, ButtonSize);
558#[proc_macro]
559pub fn register_enums(input: TokenStream) -> TokenStream {
560    let types = syn::parse_macro_input!(input with syn::punctuated::Punctuated::<syn::Type, syn::Token![,]>::parse_terminated);
561    
562    let registrations = types.iter().map(|ty| {
563        quote! {
564            #ty::__register_enum_options();
565        }
566    });
567    
568    let expanded = quote! {
569        #[wasm_bindgen::prelude::wasm_bindgen]
570        pub fn init_enums() {
571            #(#registrations)*
572        }
573    };
574    
575    TokenStream::from(expanded)
576}