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", "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 json
import asyncio import asyncio
import time import time
import requests
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple 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 Stories serve as the primary documentation and interactive reference
for how components should be used in applications. for how components should be used in applications.
Supports both React components and Web Components with JSDoc annotations.
""" """
import re import re
import json
import subprocess
import logging
from pathlib import Path 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 dataclasses import dataclass, field
from enum import Enum from enum import Enum
log = logging.getLogger(__name__)
class StoryTemplate(str, Enum): class StoryTemplate(str, Enum):
""" """
@@ -25,6 +32,15 @@ class StoryTemplate(str, Enum):
MDX = "mdx" # MDX format (documentation + interactive) 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 @dataclass
class PropInfo: class PropInfo:
""" """
@@ -54,6 +70,8 @@ class ComponentMeta:
props: List[PropInfo] = field(default_factory=list) props: List[PropInfo] = field(default_factory=list)
description: str = "" description: str = ""
has_children: bool = False has_children: bool = False
component_type: ComponentType = ComponentType.UNKNOWN
tag_name: Optional[str] = None # For Web Components (e.g., 'ds-button')
class StoryGenerator: class StoryGenerator:
@@ -63,10 +81,14 @@ class StoryGenerator:
Generates interactive Storybook stories in CSF3, CSF2, or MDX format, Generates interactive Storybook stories in CSF3, CSF2, or MDX format,
automatically extracting component metadata and creating comprehensive automatically extracting component metadata and creating comprehensive
documentation with variants and default stories. documentation with variants and default stories.
Supports both React components (.tsx, .jsx) and Web Components (.js).
""" """
def __init__(self, root_path: str): def __init__(self, root_path: str):
self.root = Path(root_path).resolve() 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]]: def generate(self, template: StoryTemplate = StoryTemplate.CSF3, dry_run: bool = True) -> List[Dict[str, str]]:
""" """
@@ -165,11 +187,168 @@ class StoryGenerator:
return story return story
async def _parse_component(self, component_path: str) -> ComponentMeta: 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) 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") content = path.read_text(encoding="utf-8", errors="ignore")
component_name = path.stem 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 = [] props = []
# Extract props from interface/type # Extract props from interface/type
@@ -216,29 +395,21 @@ class StoryGenerator:
options=options, options=options,
)) ))
# Check if component uses children return props
has_children = 'children' in content.lower() and (
'React.ReactNode' in content or
'ReactNode' in content or
'{children}' in content
)
# Extract component description from JSDoc @staticmethod
description = "" def _tag_to_pascal(tag_name: str) -> str:
jsdoc_match = re.search(r'/\*\*\s*\n\s*\*\s*([^\n*]+)', content) """Convert kebab-case tag name to PascalCase (e.g., 'ds-button' -> 'DsButton')."""
if jsdoc_match: return ''.join(word.capitalize() for word in tag_name.split('-'))
description = jsdoc_match.group(1).strip()
return ComponentMeta(
name=component_name,
path=component_path,
props=props,
description=description,
has_children=has_children,
)
def _generate_csf3(self, meta: ComponentMeta, include_variants: bool) -> str: def _generate_csf3(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF3 format story.""" """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 = [ lines = [
f"import type {{ Meta, StoryObj }} from '@storybook/react';", f"import type {{ Meta, StoryObj }} from '@storybook/react';",
f"import {{ {meta.name} }} from './{meta.name}';", f"import {{ {meta.name} }} from './{meta.name}';",
@@ -344,6 +515,204 @@ class StoryGenerator:
return "\n".join(lines) 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: def _generate_csf2(self, meta: ComponentMeta, include_variants: bool) -> str:
"""Generate CSF2 format story.""" """Generate CSF2 format story."""
lines = [ lines = [
@@ -459,6 +828,8 @@ class StoryGenerator:
""" """
Generate stories for all components in a directory. Generate stories for all components in a directory.
Supports both React components (.tsx, .jsx) and Web Components (.js).
Args: Args:
directory: Path to component directory directory: Path to component directory
template: Story template format template: Story template format
@@ -473,23 +844,42 @@ class StoryGenerator:
if not dir_path.exists(): if not dir_path.exists():
return results return results
# Find component files # Find component files (React + Web Components)
for pattern in ['*.tsx', '*.jsx']: for pattern in ['*.tsx', '*.jsx', '*.js']:
for comp_path in dir_path.glob(pattern): for comp_path in dir_path.glob(pattern):
# Skip story files, test files, index files # Skip story files, test files, index files
if any(x in comp_path.name.lower() for x in ['.stories.', '.test.', '.spec.', 'index.']): if any(x in comp_path.name.lower() for x in ['.stories.', '.test.', '.spec.', 'index.']):
continue continue
# Skip non-component files (not PascalCase) # Check if it's a valid component file
if not comp_path.stem[0].isupper(): 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 continue
try: try:
rel_path = str(comp_path.relative_to(self.root)) rel_path = str(comp_path.relative_to(self.root))
story = await self.generate_story(rel_path, template) story = await self.generate_story(rel_path, template)
# Determine story output path # Determine story output path (use .stories.js for Web Components)
story_path = comp_path.with_suffix('.stories.tsx') 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 = { result = {
'component': rel_path, 'component': rel_path,
@@ -504,6 +894,7 @@ class StoryGenerator:
results.append(result) results.append(result)
except Exception as e: except Exception as e:
log.error(f"Error generating story for {comp_path}: {e}")
results.append({ results.append({
'component': str(comp_path), 'component': str(comp_path),
'error': str(e), 'error': str(e),