vize_musea 0.9.0

Musea - Component gallery and documentation for Vize Vue components
Documentation
//! Palette code generation from Art descriptors.

use super::inference::infer_control_from_values;
use super::{Palette, PaletteOptions, PaletteOutput, PropControl};
use crate::types::ArtDescriptor;
use vize_carton::FxHashMap;

/// Generate palette configuration from an Art descriptor.
///
/// Collects all args from variants, infers control types,
/// and generates a complete palette configuration.
#[inline]
pub fn generate_palette(art: &ArtDescriptor<'_>, options: &PaletteOptions) -> PaletteOutput {
    let mut palette = Palette::new(art.metadata.title);

    // Collect all values for each prop across variants
    let mut all_values: FxHashMap<String, Vec<serde_json::Value>> = FxHashMap::default();

    for variant in &art.variants {
        for (key, value) in &variant.args {
            all_values
                .entry(key.to_string())
                .or_default()
                .push(value.clone());
        }
    }

    // Store all values in palette for reference
    palette.all_values = all_values.clone();

    // Generate controls for each prop
    let mut prop_names: Vec<_> = all_values.keys().collect();
    prop_names.sort(); // Stable ordering

    for prop_name in prop_names {
        let values = &all_values[prop_name];
        let (control_kind, select_options, range_config) =
            infer_control_from_values(values, options);

        let mut control = PropControl {
            name: prop_name.clone(),
            control: control_kind,
            default_value: values.first().cloned(),
            description: None,
            required: false,
            options: select_options,
            range: range_config,
            group: if options.group_by_type {
                Some(format!("{:?}", control_kind))
            } else {
                None
            },
        };

        // Add group if grouping by type
        if options.group_by_type {
            control.group = Some(format!("{:?}", control.control));
        }

        palette.add_control(control);
    }

    // Generate JSON representation
    let json = serde_json::to_string_pretty(&palette).unwrap_or_default();

    // Generate TypeScript interface
    let typescript = generate_typescript_interface(&palette);

    PaletteOutput {
        palette,
        json,
        typescript,
    }
}

/// Generate TypeScript interface for props.
fn generate_typescript_interface(palette: &Palette) -> String {
    let mut ts = String::with_capacity(512);

    ts.push_str("export interface ");
    ts.push_str(&to_pascal_case(&palette.title));
    ts.push_str("Props {\n");

    for control in &palette.controls {
        ts.push_str("  ");
        ts.push_str(&control.name);

        if !control.required {
            ts.push('?');
        }

        ts.push_str(": ");
        ts.push_str(&control_to_ts_type(control));
        ts.push_str(";\n");
    }

    ts.push_str("}\n");

    ts
}

/// Convert control to TypeScript type.
fn control_to_ts_type(control: &PropControl) -> String {
    use super::ControlKind;

    match control.control {
        ControlKind::Text => "string".to_string(),
        ControlKind::Number | ControlKind::Range => "number".to_string(),
        ControlKind::Boolean => "boolean".to_string(),
        ControlKind::Color => "string".to_string(),
        ControlKind::Date => "string | Date".to_string(),
        ControlKind::Select | ControlKind::Radio => {
            if control.options.is_empty() {
                "string".to_string()
            } else {
                control
                    .options
                    .iter()
                    .map(|opt| match &opt.value {
                        serde_json::Value::String(s) => format!("'{}'", s),
                        serde_json::Value::Number(n) => n.to_string(),
                        serde_json::Value::Bool(b) => b.to_string(),
                        _ => "unknown".to_string(),
                    })
                    .collect::<Vec<_>>()
                    .join(" | ")
            }
        }
        ControlKind::Array => "unknown[]".to_string(),
        ControlKind::Object => "Record<string, unknown>".to_string(),
        ControlKind::File => "File".to_string(),
        ControlKind::Raw => "unknown".to_string(),
    }
}

/// Convert string to PascalCase.
#[inline]
fn to_pascal_case(s: &str) -> String {
    s.split(|c: char| !c.is_alphanumeric())
        .filter(|s| !s.is_empty())
        .map(|word| {
            let mut chars = word.chars();
            match chars.next() {
                Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
                None => String::new(),
            }
        })
        .collect()
}

/// Generate Vue component props definition.
#[allow(dead_code)]
pub fn generate_vue_props(palette: &Palette) -> String {
    let mut vue = String::with_capacity(512);

    vue.push_str("const props = defineProps<{\n");

    for control in &palette.controls {
        vue.push_str("  ");
        vue.push_str(&control.name);

        if !control.required {
            vue.push('?');
        }

        vue.push_str(": ");
        vue.push_str(&control_to_ts_type(control));
        vue.push('\n');
    }

    vue.push_str("}>()\n");

    vue
}

/// Generate Storybook argTypes definition.
#[allow(dead_code)]
pub fn generate_storybook_argtypes(palette: &Palette) -> String {
    use super::ControlKind;

    let mut sb = String::with_capacity(1024);

    sb.push_str("export const argTypes = {\n");

    for control in &palette.controls {
        sb.push_str("  ");
        sb.push_str(&control.name);
        sb.push_str(": {\n");

        // Control type
        sb.push_str("    control: { type: '");
        sb.push_str(match control.control {
            ControlKind::Text => "text",
            ControlKind::Number => "number",
            ControlKind::Boolean => "boolean",
            ControlKind::Range => "range",
            ControlKind::Select => "select",
            ControlKind::Radio => "radio",
            ControlKind::Color => "color",
            ControlKind::Date => "date",
            ControlKind::Object => "object",
            ControlKind::Array => "object",
            ControlKind::File => "file",
            ControlKind::Raw => "text",
        });
        sb.push('\'');

        // Range config
        if let Some(ref range) = control.range {
            sb.push_str(&format!(", min: {}, max: {}", range.min, range.max));
            if let Some(step) = range.step {
                sb.push_str(&format!(", step: {}", step));
            }
        }

        sb.push_str(" },\n");

        // Options
        if !control.options.is_empty() {
            sb.push_str("    options: [");
            for (i, opt) in control.options.iter().enumerate() {
                if i > 0 {
                    sb.push_str(", ");
                }
                match &opt.value {
                    serde_json::Value::String(s) => sb.push_str(&format!("'{}'", s)),
                    serde_json::Value::Number(n) => sb.push_str(&n.to_string()),
                    serde_json::Value::Bool(b) => sb.push_str(&b.to_string()),
                    _ => sb.push_str("null"),
                }
            }
            sb.push_str("],\n");
        }

        // Description
        if let Some(ref desc) = control.description {
            sb.push_str(&format!("    description: '{}',\n", desc));
        }

        // Default value
        if let Some(ref default) = control.default_value {
            sb.push_str("    defaultValue: ");
            match default {
                serde_json::Value::String(s) => sb.push_str(&format!("'{}'", s)),
                serde_json::Value::Number(n) => sb.push_str(&n.to_string()),
                serde_json::Value::Bool(b) => sb.push_str(&b.to_string()),
                _ => sb.push_str(&default.to_string()),
            }
            sb.push_str(",\n");
        }

        // Table category (group)
        if let Some(ref group) = control.group {
            sb.push_str(&format!("    table: {{ category: '{}' }},\n", group));
        }

        sb.push_str("  },\n");
    }

    sb.push_str("};\n");

    sb
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{parse_art, ArtParseOptions, Bump};

    #[test]
    fn test_generate_palette_basic() {
        let allocator = Bump::new();
        let source = r#"
<art title="Button">
  <variant name="Primary" args='{"variant":"primary","size":"md","disabled":false}'>
    <Button>Click</Button>
  </variant>
  <variant name="Secondary" args='{"variant":"secondary","size":"lg","disabled":true}'>
    <Button>Click</Button>
  </variant>
</art>
"#;

        let art = parse_art(&allocator, source, ArtParseOptions::default()).unwrap();
        let output = generate_palette(&art, &PaletteOptions::default());

        assert_eq!(output.palette.title, "Button");
        assert_eq!(output.palette.controls.len(), 3);

        // Check variant control is select with options
        let variant_ctrl = output
            .palette
            .controls
            .iter()
            .find(|c| c.name == "variant")
            .unwrap();
        assert_eq!(variant_ctrl.control, super::super::ControlKind::Select);
        assert_eq!(variant_ctrl.options.len(), 2);

        // Check disabled control is boolean
        let disabled_ctrl = output
            .palette
            .controls
            .iter()
            .find(|c| c.name == "disabled")
            .unwrap();
        assert_eq!(disabled_ctrl.control, super::super::ControlKind::Boolean);
    }

    #[test]
    fn test_generate_typescript_interface() {
        let mut palette = Palette::new("Button");
        palette.add_control(PropControl::text("label").required());
        palette.add_control(PropControl::boolean("disabled"));
        palette.add_control(PropControl::number("size"));

        let ts = generate_typescript_interface(&palette);

        assert!(ts.contains("export interface ButtonProps"));
        assert!(ts.contains("label: string;"));
        assert!(ts.contains("disabled?: boolean;"));
        assert!(ts.contains("size?: number;"));
    }

    #[test]
    fn test_generate_storybook_argtypes() {
        let mut palette = Palette::new("Button");
        palette.add_control(
            PropControl::select(
                "size",
                vec![
                    super::super::SelectOption {
                        label: "Small".to_string(),
                        value: serde_json::json!("sm"),
                    },
                    super::super::SelectOption {
                        label: "Large".to_string(),
                        value: serde_json::json!("lg"),
                    },
                ],
            )
            .with_default(serde_json::json!("sm")),
        );

        let argtypes = generate_storybook_argtypes(&palette);

        assert!(argtypes.contains("control: { type: 'select' }"));
        assert!(argtypes.contains("options: ['sm', 'lg']"));
        assert!(argtypes.contains("defaultValue: 'sm'"));
    }

    #[test]
    fn test_to_pascal_case() {
        assert_eq!(to_pascal_case("button"), "Button");
        assert_eq!(to_pascal_case("my-button"), "MyButton");
        assert_eq!(to_pascal_case("my_button"), "MyButton");
        assert_eq!(to_pascal_case("MyButton"), "MyButton");
    }
}