Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm
Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)
Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability
Migration completed: $(date)
🤖 Clean migration with full functionality preserved
434 lines
14 KiB
Python
434 lines
14 KiB
Python
"""
|
|
Storybook Story Generator
|
|
|
|
Generates Storybook stories from React components.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import List, Dict, Any, Optional
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
|
|
|
|
class StoryTemplate(str, Enum):
|
|
"""Available story templates."""
|
|
CSF3 = "csf3" # Component Story Format 3 (latest)
|
|
CSF2 = "csf2" # Component Story Format 2
|
|
MDX = "mdx" # MDX format
|
|
|
|
|
|
@dataclass
|
|
class PropInfo:
|
|
"""Information about a component prop."""
|
|
name: str
|
|
type: str = "unknown"
|
|
required: bool = False
|
|
default_value: Optional[str] = None
|
|
description: str = ""
|
|
options: List[str] = field(default_factory=list) # For enum/union types
|
|
|
|
|
|
@dataclass
|
|
class ComponentMeta:
|
|
"""Metadata about a component for story generation."""
|
|
name: str
|
|
path: str
|
|
props: List[PropInfo] = field(default_factory=list)
|
|
description: str = ""
|
|
has_children: bool = False
|
|
|
|
|
|
class StoryGenerator:
|
|
"""
|
|
Generates Storybook stories from component information.
|
|
"""
|
|
|
|
def __init__(self, root_path: str):
|
|
self.root = Path(root_path).resolve()
|
|
|
|
async def generate_story(
|
|
self,
|
|
component_path: str,
|
|
template: StoryTemplate = StoryTemplate.CSF3,
|
|
include_variants: bool = True,
|
|
output_path: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
Generate a Storybook story for a component.
|
|
|
|
Args:
|
|
component_path: Path to the component file
|
|
template: Story template format
|
|
include_variants: Generate variant stories
|
|
output_path: Optional path to write the story file
|
|
|
|
Returns:
|
|
Generated story code
|
|
"""
|
|
# Parse component
|
|
meta = await self._parse_component(component_path)
|
|
|
|
# Generate story based on template
|
|
if template == StoryTemplate.CSF3:
|
|
story = self._generate_csf3(meta, include_variants)
|
|
elif template == StoryTemplate.CSF2:
|
|
story = self._generate_csf2(meta, include_variants)
|
|
else:
|
|
story = self._generate_mdx(meta, include_variants)
|
|
|
|
# Write to file if output path provided
|
|
if output_path:
|
|
output = Path(output_path)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(story)
|
|
|
|
return story
|
|
|
|
async def _parse_component(self, component_path: str) -> ComponentMeta:
|
|
"""Parse a React component to extract metadata."""
|
|
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
|
|
props = []
|
|
|
|
# Extract props from interface/type
|
|
# interface ButtonProps { variant?: 'primary' | 'secondary'; ... }
|
|
props_pattern = re.compile(
|
|
r'(?:interface|type)\s+\w*Props\s*(?:=\s*)?\{([^}]+)\}',
|
|
re.DOTALL
|
|
)
|
|
|
|
props_match = props_pattern.search(content)
|
|
if props_match:
|
|
props_content = props_match.group(1)
|
|
|
|
# Parse each prop line
|
|
for line in props_content.split('\n'):
|
|
line = line.strip()
|
|
if not line or line.startswith('//'):
|
|
continue
|
|
|
|
# Match: propName?: type; or propName: type;
|
|
prop_match = re.match(
|
|
r'(\w+)(\?)?:\s*([^;/]+)',
|
|
line
|
|
)
|
|
if prop_match:
|
|
prop_name = prop_match.group(1)
|
|
is_optional = prop_match.group(2) == '?'
|
|
prop_type = prop_match.group(3).strip()
|
|
|
|
# Extract options from union types
|
|
options = []
|
|
if '|' in prop_type:
|
|
# 'primary' | 'secondary' | 'ghost'
|
|
options = [
|
|
o.strip().strip("'\"")
|
|
for o in prop_type.split('|')
|
|
if o.strip().startswith(("'", '"'))
|
|
]
|
|
|
|
props.append(PropInfo(
|
|
name=prop_name,
|
|
type=prop_type,
|
|
required=not is_optional,
|
|
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
|
|
)
|
|
|
|
# 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,
|
|
)
|
|
|
|
def _generate_csf3(self, meta: ComponentMeta, include_variants: bool) -> str:
|
|
"""Generate CSF3 format story."""
|
|
lines = [
|
|
f"import type {{ Meta, StoryObj }} from '@storybook/react';",
|
|
f"import {{ {meta.name} }} from './{meta.name}';",
|
|
"",
|
|
f"const meta: Meta<typeof {meta.name}> = {{",
|
|
f" title: 'Components/{meta.name}',",
|
|
f" component: {meta.name},",
|
|
" parameters: {",
|
|
" layout: 'centered',",
|
|
" },",
|
|
" tags: ['autodocs'],",
|
|
]
|
|
|
|
# Add argTypes for props with options
|
|
arg_types = []
|
|
for prop in meta.props:
|
|
if prop.options:
|
|
arg_types.append(
|
|
f" {prop.name}: {{\n"
|
|
f" options: {prop.options},\n"
|
|
f" control: {{ type: 'select' }},\n"
|
|
f" }},"
|
|
)
|
|
|
|
if arg_types:
|
|
lines.append(" argTypes: {")
|
|
lines.extend(arg_types)
|
|
lines.append(" },")
|
|
|
|
lines.extend([
|
|
"};",
|
|
"",
|
|
"export default meta;",
|
|
f"type Story = StoryObj<typeof {meta.name}>;",
|
|
"",
|
|
])
|
|
|
|
# Generate default story
|
|
default_args = self._get_default_args(meta)
|
|
lines.extend([
|
|
"export const Default: Story = {",
|
|
" args: {",
|
|
])
|
|
for key, value in default_args.items():
|
|
lines.append(f" {key}: {value},")
|
|
lines.extend([
|
|
" },",
|
|
"};",
|
|
])
|
|
|
|
# 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}: Story = {{",
|
|
" args: {",
|
|
f" ...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()}"
|
|
lines.extend([
|
|
"",
|
|
f"export const {story_name}: Story = {{",
|
|
" args: {",
|
|
f" ...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: Story = {",
|
|
" args: {",
|
|
" ...Default.args,",
|
|
" disabled: true,",
|
|
" },",
|
|
"};",
|
|
])
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _generate_csf2(self, meta: ComponentMeta, include_variants: bool) -> str:
|
|
"""Generate CSF2 format story."""
|
|
lines = [
|
|
f"import React from 'react';",
|
|
f"import {{ {meta.name} }} from './{meta.name}';",
|
|
"",
|
|
"export default {",
|
|
f" title: 'Components/{meta.name}',",
|
|
f" component: {meta.name},",
|
|
"};",
|
|
"",
|
|
f"const Template = (args) => <{meta.name} {{...args}} />;",
|
|
"",
|
|
"export const Default = Template.bind({});",
|
|
"Default.args = {",
|
|
]
|
|
|
|
default_args = self._get_default_args(meta)
|
|
for key, value in default_args.items():
|
|
lines.append(f" {key}: {value},")
|
|
|
|
lines.append("};")
|
|
|
|
# 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} = Template.bind({{}});",
|
|
f"{story_name}.args = {{",
|
|
f" ...Default.args,",
|
|
f" variant: '{variant}',",
|
|
"};",
|
|
])
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _generate_mdx(self, meta: ComponentMeta, include_variants: bool) -> str:
|
|
"""Generate MDX format story."""
|
|
lines = [
|
|
f"import {{ Meta, Story, Canvas, ArgsTable }} from '@storybook/blocks';",
|
|
f"import {{ {meta.name} }} from './{meta.name}';",
|
|
"",
|
|
f"<Meta title=\"Components/{meta.name}\" component={{{meta.name}}} />",
|
|
"",
|
|
f"# {meta.name}",
|
|
"",
|
|
]
|
|
|
|
if meta.description:
|
|
lines.extend([meta.description, ""])
|
|
|
|
lines.extend([
|
|
"## Default",
|
|
"",
|
|
"<Canvas>",
|
|
f" <Story name=\"Default\">",
|
|
f" <{meta.name}",
|
|
])
|
|
|
|
default_args = self._get_default_args(meta)
|
|
for key, value in default_args.items():
|
|
lines.append(f" {key}={value}")
|
|
|
|
lines.extend([
|
|
f" />",
|
|
" </Story>",
|
|
"</Canvas>",
|
|
"",
|
|
"## Props",
|
|
"",
|
|
f"<ArgsTable of={{{meta.name}}} />",
|
|
])
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _get_default_args(self, meta: ComponentMeta) -> Dict[str, str]:
|
|
"""Get default args for a component."""
|
|
args = {}
|
|
|
|
for prop in meta.props:
|
|
if prop.name == 'children' and meta.has_children:
|
|
args['children'] = f"'{meta.name}'"
|
|
elif prop.name == 'variant' and prop.options:
|
|
args['variant'] = f"'{prop.options[0]}'"
|
|
elif prop.name == 'size' and prop.options:
|
|
args['size'] = f"'{prop.options[0]}'"
|
|
elif prop.name == 'disabled':
|
|
args['disabled'] = 'false'
|
|
elif prop.name == 'onClick':
|
|
args['onClick'] = '() => console.log("clicked")'
|
|
elif prop.required and prop.default_value:
|
|
args[prop.name] = prop.default_value
|
|
|
|
# Ensure children for button-like components
|
|
if meta.has_children and 'children' not in args:
|
|
args['children'] = f"'{meta.name}'"
|
|
|
|
return args
|
|
|
|
async def generate_stories_for_directory(
|
|
self,
|
|
directory: str,
|
|
template: StoryTemplate = StoryTemplate.CSF3,
|
|
dry_run: bool = True,
|
|
) -> List[Dict[str, str]]:
|
|
"""
|
|
Generate stories for all components in a directory.
|
|
|
|
Args:
|
|
directory: Path to component directory
|
|
template: Story template format
|
|
dry_run: If True, only return what would be generated
|
|
|
|
Returns:
|
|
List of dicts with component path and generated story
|
|
"""
|
|
results = []
|
|
dir_path = self.root / directory
|
|
|
|
if not dir_path.exists():
|
|
return results
|
|
|
|
# Find component files
|
|
for pattern in ['*.tsx', '*.jsx']:
|
|
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():
|
|
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')
|
|
|
|
result = {
|
|
'component': rel_path,
|
|
'story_path': str(story_path.relative_to(self.root)),
|
|
'story': story,
|
|
}
|
|
|
|
if not dry_run:
|
|
story_path.write_text(story)
|
|
result['written'] = True
|
|
|
|
results.append(result)
|
|
|
|
except Exception as e:
|
|
results.append({
|
|
'component': str(comp_path),
|
|
'error': str(e),
|
|
})
|
|
|
|
return results
|