feat(storybook): Add Web Component support to story generator
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
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:
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,22 +844,41 @@ 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
|
||||
# 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 = {
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user