Initial commit: Clean DSS implementation
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
This commit is contained in:
433
tools/storybook/generator.py
Normal file
433
tools/storybook/generator.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user