Skip to content

refactor(docs-rs): Make Web Component generator discovery-driven with knobs config output #767

@ssilvius

Description

@ssilvius

Goal

Rewrite crates/rafters-adapters/src/generator.rs to use the discovered element_tag, has_children, and self_closing from ComponentStructure instead of hardcoding <button>. Add knobs_config field to TransformedBlock for playground controls integration. Remove all hardcoded element behaviors.

Exact Implementation Requirements

Required Changes to TransformedBlock

// crates/rafters-adapters/src/traits.rs

#[derive(Debug, Clone)]
pub struct TransformedBlock {
    pub web_component: String,
    pub tag_name: String,
    pub classes_used: Vec<String>,
    pub attributes: Vec<String>,
    // NEW:
    /// JSON config for knobs controls (variant keys, size keys, boolean props).
    /// Empty string when no knobs needed.
    pub knobs_config: String,
}

Required Changes to generate_web_component

// crates/rafters-adapters/src/generator.rs

/// Generate a Web Component class from the extracted component structure.
/// Uses the discovered element tag and children support -- no hardcoded elements.
pub fn generate_web_component(tag_name: &str, structure: &ComponentStructure) -> String

/// Generate JSON config for knobs controls from discovered component metadata.
pub fn generate_knobs_config(structure: &ComponentStructure) -> String

Behavior Requirements

  • Use structure.element_tag in document.createElement(...) instead of hardcoded 'button'
  • Rename internal #button field to #el
  • Remove this.#button.type = 'button' -- only a <button> needs that, and the generator should not assume element type
  • If structure.has_children is true: add a <slot> element inside #el
  • If structure.has_children is false (self-closing elements like <input>): no slot, no child content
  • Remove all hardcoded loading attribute handling (the "Loading..." text, aria-busy)
  • Remove this.#button.disabled = isDisabled -- use setAttribute/removeAttribute('disabled', '') generically for any element
  • disabled attribute handling: only add if "disabled" is in structure.observed_attributes
  • Apply classes from baseClasses + variantClasses[variant] + sizeClasses[size] (existing logic, keep)
  • Apply disabled classes only if "disabled" is in observed attributes AND element has disabled attribute set
  • generate_knobs_config produces JSON:
    {
      "variants": ["default", "destructive", "outline"],
      "sizes": ["sm", "default", "lg"],
      "booleans": ["disabled"]
    }
    Where variants = keys from variant_lookup, sizes = keys from size_lookup, booleans = observed attributes that are typically boolean (heuristic: name matches common boolean patterns like disabled, loading, checked, readonly, required, hidden, open, or the prop was typed as boolean in the interface)

What to Remove

  • this.#button = document.createElement('button') -> this.#el = document.createElement('{element_tag}')
  • this.#button.type = 'button' (hardcoded button behavior)
  • loading special case with "Loading..." span
  • aria-busy for loading
  • All references to #button -> #el

Error Handling

  • None specific -- generator always produces valid JS from valid ComponentStructure

Acceptance Criteria

Functional Tests Required

#[cfg(test)]
mod tests {
    use super::*;
    use crate::react::ComponentStructure;

    #[test]
    fn generates_button_element() {
        let structure = ComponentStructure {
            element_tag: "button".to_string(),
            has_children: true,
            self_closing: false,
            variant_lookup: vec![("primary".into(), "bg-primary".into())],
            ..Default::default()
        };
        let output = generate_web_component("my-button", &structure);
        assert!(output.contains("createElement('button')"));
        assert!(output.contains("slot")); // has children -> slot
        assert!(!output.contains("Loading")); // no hardcoded loading
    }

    #[test]
    fn generates_input_element_no_slot() {
        let structure = ComponentStructure {
            element_tag: "input".to_string(),
            has_children: false,
            self_closing: true,
            variant_lookup: vec![("default".into(), "border".into())],
            ..Default::default()
        };
        let output = generate_web_component("my-input", &structure);
        assert!(output.contains("createElement('input')"));
        assert!(!output.contains("slot")); // no children -> no slot
    }

    #[test]
    fn generates_span_element() {
        let structure = ComponentStructure {
            element_tag: "span".to_string(),
            has_children: true,
            self_closing: false,
            variant_lookup: vec![("default".into(), "bg-primary".into())],
            ..Default::default()
        };
        let output = generate_web_component("my-badge", &structure);
        assert!(output.contains("createElement('span')"));
        assert!(output.contains("slot"));
    }

    #[test]
    fn no_hardcoded_button_type() {
        let structure = ComponentStructure {
            element_tag: "div".to_string(),
            has_children: true,
            ..Default::default()
        };
        let output = generate_web_component("my-card", &structure);
        assert!(!output.contains(".type = 'button'"));
    }

    #[test]
    fn generates_knobs_config() {
        let structure = ComponentStructure {
            variant_lookup: vec![
                ("primary".into(), "bg-primary".into()),
                ("secondary".into(), "bg-secondary".into()),
            ],
            size_lookup: vec![
                ("sm".into(), "h-8".into()),
                ("lg".into(), "h-12".into()),
            ],
            observed_attributes: vec!["variant".into(), "size".into(), "disabled".into()],
            ..Default::default()
        };
        let config = generate_knobs_config(&structure);
        assert!(config.contains("primary"));
        assert!(config.contains("secondary"));
        assert!(config.contains("sm"));
        assert!(config.contains("disabled"));
    }

    #[test]
    fn uses_el_not_button() {
        let structure = ComponentStructure {
            element_tag: "button".to_string(),
            ..Default::default()
        };
        let output = generate_web_component("test-el", &structure);
        assert!(output.contains("#el"));
        assert!(!output.contains("#button"));
    }
}

Rust Requirements

  • Must compile with cargo clippy --all-targets -- -D warnings
  • Generated JS must use safe DOM APIs only (createElement, setAttribute, appendChild, etc.)
  • No innerHTML in generated JS
  • Keep existing to_pascal_case and escape_string helpers

What NOT to Include

  • The <rafters-knobs> Web Component itself (separate issue)
  • Template integration (separate issue)
  • BlockMode changes (separate issue)

File Locations

  • Implementation: crates/rafters-adapters/src/generator.rs
  • Trait update: crates/rafters-adapters/src/traits.rs (add knobs_config field)
  • Tests: inline in generator.rs

Integration Requirements

Dependencies

Success Criteria

  • All tests pass
  • Zero hardcoded element types in generated JS
  • No "Loading..." text anywhere
  • No #button references -- all #el
  • Slot only rendered when has_children is true
  • knobs_config JSON generated from discovered metadata
  • cargo clippy -p rafters-adapters --all-targets clean

This issue is complete when: generate_web_component() produces element-agnostic Web Components that render the discovered HTML element with conditional slot support, and generate_knobs_config() produces JSON from discovered metadata with zero hardcoded assumptions.

Context & References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions