feat(storybook): Add Web Component support to story generator
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled

The Storybook generator now supports Web Components in addition to React:

- Added ComponentType enum (REACT, WEB_COMPONENT)
- Enhanced _parse_component to detect Web Components via HTMLElement/customElements
- Added _parse_web_component to extract attributes from JSDoc and observedAttributes
- Added _generate_csf3_web_component for Web Component story generation
- Updated file patterns to include .js files
- Moved ds-button.js to src/components/ for proper discovery
- Fixed missing requests import in figma.py

The generator now successfully generates Storybook stories for admin-ui's
ds-button Web Component with all variants, sizes, and states.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DSS
2025-12-10 14:02:01 -03:00
parent 23bfc9fd54
commit 1644110cee
4 changed files with 421 additions and 29 deletions

View File

@@ -24,5 +24,5 @@
]
},
"created_at": "2025-12-10T12:52:18.513773",
"updated_at": "2025-12-10T13:01:40.642236"
"updated_at": "2025-12-10T13:46:05.807775"
}

View File

@@ -9,6 +9,7 @@ import os
import json
import asyncio
import time
import requests
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

View File

@@ -7,14 +7,21 @@ variants, and integration points.
Stories serve as the primary documentation and interactive reference
for how components should be used in applications.
Supports both React components and Web Components with JSDoc annotations.
"""
import re
import json
import subprocess
import logging
from pathlib import Path
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, field
from enum import Enum
log = logging.getLogger(__name__)
class StoryTemplate(str, Enum):
"""
@@ -25,6 +32,15 @@ class StoryTemplate(str, Enum):
MDX = "mdx" # MDX format (documentation + interactive)
class ComponentType(str, Enum):
"""
Type of component detected during parsing.
"""
REACT = "react" # React functional or class component
WEB_COMPONENT = "web" # Custom Element / Web Component
UNKNOWN = "unknown"
@dataclass
class PropInfo:
"""
@@ -54,6 +70,8 @@ class ComponentMeta:
props: List[PropInfo] = field(default_factory=list)
description: str = ""
has_children: bool = False
component_type: ComponentType = ComponentType.UNKNOWN
tag_name: Optional[str] = None # For Web Components (e.g., 'ds-button')
class StoryGenerator:
@@ -63,10 +81,14 @@ class StoryGenerator:
Generates interactive Storybook stories in CSF3, CSF2, or MDX format,
automatically extracting component metadata and creating comprehensive
documentation with variants and default stories.
Supports both React components (.tsx, .jsx) and Web Components (.js).
"""
def __init__(self, root_path: str):
self.root = Path(root_path).resolve()
# Path to the Babel parser script (shared with project_analyzer)
self._parser_script = Path(__file__).parent.parent / 'analyze' / 'parser.js'
def generate(self, template: StoryTemplate = StoryTemplate.CSF3, dry_run: bool = True) -> List[Dict[str, str]]:
"""
@@ -165,11 +187,168 @@ class StoryGenerator:
return story
async def _parse_component(self, component_path: str) -> ComponentMeta:
"""Parse a React component to extract metadata."""
"""
Parse a component to extract metadata using Babel AST.
Supports both React components (TypeScript interfaces) and
Web Components (JSDoc annotations, observedAttributes, customElements.define).
"""
path = self.root / component_path if not Path(component_path).is_absolute() else Path(component_path)
content = path.read_text(encoding="utf-8", errors="ignore")
component_name = path.stem
# Try to determine component type and parse accordingly
component_type = ComponentType.UNKNOWN
tag_name = None
props = []
description = ""
has_children = False
# Detect Web Component patterns
is_web_component = (
'extends HTMLElement' in content or
'customElements.define' in content
)
if is_web_component:
component_type = ComponentType.WEB_COMPONENT
props, tag_name, description, has_children = self._parse_web_component(content)
# Convert tag name to PascalCase for story naming
if tag_name:
component_name = self._tag_to_pascal(tag_name)
else:
# Try React component parsing (TypeScript interfaces)
component_type = ComponentType.REACT
props = self._parse_react_props(content)
# Check if component uses children (React)
has_children = 'children' in content.lower() and (
'React.ReactNode' in content or
'ReactNode' in content or
'{children}' in content
)
# Extract description from JSDoc if not already found
if not description:
jsdoc_match = re.search(r'/\*\*\s*\n\s*\*\s*([^\n*]+)', content)
if jsdoc_match:
description = jsdoc_match.group(1).strip()
return ComponentMeta(
name=component_name,
path=component_path,
props=props,
description=description,
has_children=has_children,
component_type=component_type,
tag_name=tag_name,
)
def _parse_web_component(self, content: str) -> Tuple[List[PropInfo], Optional[str], str, bool]:
"""
Parse Web Component to extract attributes from JSDoc and observedAttributes.
Returns:
Tuple of (props, tag_name, description, has_children)
"""
props = []
tag_name = None
description = ""
has_children = False
# Extract tag name from customElements.define('tag-name', ClassName)
define_match = re.search(
r"customElements\.define\s*\(\s*['\"]([^'\"]+)['\"]",
content
)
if define_match:
tag_name = define_match.group(1)
# Extract observedAttributes
# static get observedAttributes() { return ['variant', 'size', ...]; }
observed_match = re.search(
r"static\s+get\s+observedAttributes\s*\(\s*\)\s*\{\s*return\s*\[([^\]]+)\]",
content,
re.DOTALL
)
observed_attrs = []
if observed_match:
attrs_str = observed_match.group(1)
# Extract quoted strings
observed_attrs = re.findall(r"['\"]([^'\"]+)['\"]", attrs_str)
# Parse JSDoc to extract attribute info
# Look for @param or attribute descriptions in JSDoc
jsdoc_match = re.search(r'/\*\*([^*]|\*(?!/))*\*/', content, re.DOTALL)
if jsdoc_match:
jsdoc = jsdoc_match.group(0)
# Extract main description (first line after /**)
desc_match = re.search(r'/\*\*\s*\n?\s*\*?\s*([^\n@*]+)', jsdoc)
if desc_match:
description = desc_match.group(1).strip()
# Extract attributes section
# Attributes:
# - variant: primary | secondary | ...
# - size: sm | default | lg
attrs_section = re.search(
r'Attributes:\s*\n((?:\s*\*?\s*-[^\n]+\n?)+)',
jsdoc,
re.IGNORECASE
)
if attrs_section:
attr_lines = attrs_section.group(1)
for line in attr_lines.split('\n'):
line = line.strip().lstrip('*').strip()
if not line.startswith('-'):
continue
# Parse: - attr_name: value1 | value2 | ...
attr_match = re.match(r'-\s*(\w+):\s*(.+)', line)
if attr_match:
attr_name = attr_match.group(1)
attr_values = attr_match.group(2).strip()
# Parse options from pipe-separated values
options = []
if '|' in attr_values:
options = [v.strip() for v in attr_values.split('|')]
prop_type = 'string'
if attr_values == 'boolean':
prop_type = 'boolean'
elif options:
prop_type = ' | '.join(f"'{o}'" for o in options)
props.append(PropInfo(
name=attr_name,
type=prop_type,
required=False,
options=options,
))
# Add any observed attributes not found in JSDoc
jsdoc_attr_names = {p.name for p in props}
for attr in observed_attrs:
if attr not in jsdoc_attr_names:
# Skip ARIA attributes for story generation
if attr.startswith('aria-') or attr == 'tabindex':
continue
props.append(PropInfo(
name=attr,
type='string',
required=False,
))
# Check for slot usage (Web Component children)
has_children = '<slot' in content or '<slot>' in content
return props, tag_name, description, has_children
def _parse_react_props(self, content: str) -> List[PropInfo]:
"""Parse TypeScript interface/type for React component props."""
props = []
# Extract props from interface/type
@@ -216,29 +395,21 @@ class StoryGenerator:
options=options,
))
# Check if component uses children
has_children = 'children' in content.lower() and (
'React.ReactNode' in content or
'ReactNode' in content or
'{children}' in content
)
return props
# Extract component description from JSDoc
description = ""
jsdoc_match = re.search(r'/\*\*\s*\n\s*\*\s*([^\n*]+)', content)
if jsdoc_match:
description = jsdoc_match.group(1).strip()
return ComponentMeta(
name=component_name,
path=component_path,
props=props,
description=description,
has_children=has_children,
)
@staticmethod
def _tag_to_pascal(tag_name: str) -> str:
"""Convert kebab-case tag name to PascalCase (e.g., 'ds-button' -> 'DsButton')."""
return ''.join(word.capitalize() for word in tag_name.split('-'))
def _generate_csf3(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF3 format story."""
if meta.component_type == ComponentType.WEB_COMPONENT:
return self._generate_csf3_web_component(meta, include_variants)
return self._generate_csf3_react(meta, include_variants)
def _generate_csf3_react(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF3 format story for React components."""
lines = [
f"import type {{ Meta, StoryObj }} from '@storybook/react';",
f"import {{ {meta.name} }} from './{meta.name}';",
@@ -344,6 +515,204 @@ class StoryGenerator:
return "\n".join(lines)
def _generate_csf3_web_component(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF3 format story for Web Components using @storybook/web-components."""
tag_name = meta.tag_name or meta.name.lower()
lines = [
"/**",
f" * Storybook stories for <{tag_name}> Web Component",
f" * Auto-generated by DSS Storybook Generator",
" */",
"",
"// Import the component to ensure it's registered",
f"import './{meta.path.split('/')[-1]}';",
"",
]
# Build argTypes object
arg_types_entries = []
for prop in meta.props:
if prop.options:
options_str = json.dumps(prop.options)
arg_types_entries.append(
f" {prop.name}: {{\n"
f" options: {options_str},\n"
f" control: {{ type: 'select' }},\n"
f" description: '{prop.description or prop.name + ' attribute'}',\n"
f" }}"
)
elif prop.type == 'boolean':
arg_types_entries.append(
f" {prop.name}: {{\n"
f" control: {{ type: 'boolean' }},\n"
f" description: '{prop.description or prop.name + ' attribute'}',\n"
f" }}"
)
else:
arg_types_entries.append(
f" {prop.name}: {{\n"
f" control: {{ type: 'text' }},\n"
f" description: '{prop.description or prop.name + ' attribute'}',\n"
f" }}"
)
lines.extend([
"export default {",
f" title: 'Web Components/{meta.name}',",
" parameters: {",
" layout: 'centered',",
" docs: {",
f" description: {{ component: `{meta.description or f'{meta.name} Web Component'}` }},",
" },",
" },",
" tags: ['autodocs'],",
" argTypes: {",
])
lines.append(",\n".join(arg_types_entries))
lines.extend([
" },",
"};",
"",
])
# Generate render function
lines.extend([
"/**",
" * Render function that creates the Web Component with attributes",
" */",
"const render = (args) => {",
f" const el = document.createElement('{tag_name}');",
"",
" // Set attributes from args",
" Object.entries(args).forEach(([key, value]) => {",
" if (key === 'children' || key === 'slot') {",
" el.innerHTML = value;",
" } else if (typeof value === 'boolean') {",
" if (value) el.setAttribute(key, '');",
" else el.removeAttribute(key);",
" } else if (value !== undefined && value !== null) {",
" el.setAttribute(key, String(value));",
" }",
" });",
"",
" return el;",
"};",
"",
])
# Get default args
default_args = self._get_default_args_web_component(meta)
default_args_str = json.dumps(default_args, indent=2).replace('\n', '\n ')
# Generate default story
lines.extend([
"/**",
" * Default story showing the component in its default state",
" */",
"export const Default = {",
" render,",
f" args: {default_args_str},",
"};",
])
# Generate variant stories
if include_variants:
variant_prop = next(
(p for p in meta.props if p.name == 'variant' and p.options),
None
)
if variant_prop:
for variant in variant_prop.options:
story_name = variant.title().replace('-', '').replace('_', '')
lines.extend([
"",
f"export const {story_name} = {{",
" render,",
" args: {",
" ...Default.args,",
f" variant: '{variant}',",
" },",
"};",
])
# Size variants
size_prop = next(
(p for p in meta.props if p.name == 'size' and p.options),
None
)
if size_prop:
for size in size_prop.options:
story_name = f"Size{size.title().replace('-', '')}"
lines.extend([
"",
f"export const {story_name} = {{",
" render,",
" args: {",
" ...Default.args,",
f" size: '{size}',",
" },",
"};",
])
# Disabled state
disabled_prop = next(
(p for p in meta.props if p.name == 'disabled'),
None
)
if disabled_prop:
lines.extend([
"",
"export const Disabled = {",
" render,",
" args: {",
" ...Default.args,",
" disabled: true,",
" },",
"};",
])
# Loading state (common for buttons)
loading_prop = next(
(p for p in meta.props if p.name == 'loading'),
None
)
if loading_prop:
lines.extend([
"",
"export const Loading = {",
" render,",
" args: {",
" ...Default.args,",
" loading: true,",
" },",
"};",
])
return "\n".join(lines)
def _get_default_args_web_component(self, meta: ComponentMeta) -> Dict[str, Any]:
"""Get default args for a Web Component."""
args = {}
for prop in meta.props:
if prop.name == 'variant' and prop.options:
args['variant'] = prop.options[0]
elif prop.name == 'size' and prop.options:
args['size'] = prop.options[0]
elif prop.name == 'type' and prop.options:
args['type'] = prop.options[0]
elif prop.name == 'disabled':
args['disabled'] = False
elif prop.name == 'loading':
args['loading'] = False
# Add children/slot content for components with slots
if meta.has_children:
args['children'] = f'Click me'
return args
def _generate_csf2(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF2 format story."""
lines = [
@@ -459,6 +828,8 @@ class StoryGenerator:
"""
Generate stories for all components in a directory.
Supports both React components (.tsx, .jsx) and Web Components (.js).
Args:
directory: Path to component directory
template: Story template format
@@ -473,23 +844,42 @@ class StoryGenerator:
if not dir_path.exists():
return results
# Find component files
for pattern in ['*.tsx', '*.jsx']:
# Find component files (React + Web Components)
for pattern in ['*.tsx', '*.jsx', '*.js']:
for comp_path in dir_path.glob(pattern):
# Skip story files, test files, index files
if any(x in comp_path.name.lower() for x in ['.stories.', '.test.', '.spec.', 'index.']):
continue
# Skip non-component files (not PascalCase)
if not comp_path.stem[0].isupper():
# Check if it's a valid component file
is_valid = False
# React components: PascalCase naming (e.g., Button.tsx)
if comp_path.suffix in ['.tsx', '.jsx'] and comp_path.stem[0].isupper():
is_valid = True
# Web Components: check file content for class extends HTMLElement
elif comp_path.suffix == '.js':
try:
content = comp_path.read_text(encoding='utf-8', errors='ignore')
if 'extends HTMLElement' in content or 'customElements.define' in content:
is_valid = True
log.debug(f"Found Web Component: {comp_path}")
except Exception:
pass
if not is_valid:
continue
try:
rel_path = str(comp_path.relative_to(self.root))
story = await self.generate_story(rel_path, template)
# Determine story output path
story_path = comp_path.with_suffix('.stories.tsx')
# Determine story output path (use .stories.js for Web Components)
if comp_path.suffix == '.js':
story_path = comp_path.with_name(comp_path.stem + '.stories.js')
else:
story_path = comp_path.with_suffix('.stories.tsx')
result = {
'component': rel_path,
@@ -504,6 +894,7 @@ class StoryGenerator:
results.append(result)
except Exception as e:
log.error(f"Error generating story for {comp_path}: {e}")
results.append({
'component': str(comp_path),
'error': str(e),