Initial commit
This commit is contained in:
476
skills/visual-regression/functions/story_generator.py
Normal file
476
skills/visual-regression/functions/story_generator.py
Normal file
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Storybook Story Generator
|
||||
|
||||
Analyzes React/Vue/Svelte components and generates comprehensive Storybook stories
|
||||
with variants, accessibility tests, and interaction tests.
|
||||
|
||||
Usage:
|
||||
python story_generator.py <component_path> <framework> [template_path]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def extract_component_name(file_path: str) -> str:
|
||||
"""Extract component name from file path."""
|
||||
return Path(file_path).stem
|
||||
|
||||
|
||||
def analyze_react_component(component_path: str) -> Dict:
|
||||
"""
|
||||
Analyze React/TypeScript component to extract props and metadata.
|
||||
|
||||
Args:
|
||||
component_path: Path to component file
|
||||
|
||||
Returns:
|
||||
Dict with component info: name, props, prop_types, exports
|
||||
"""
|
||||
with open(component_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
component_name = extract_component_name(component_path)
|
||||
|
||||
result = {
|
||||
'name': component_name,
|
||||
'path': component_path,
|
||||
'props': [],
|
||||
'has_typescript': component_path.endswith(('.tsx', '.ts')),
|
||||
'is_default_export': False,
|
||||
'story_title': f'Components/{component_name}'
|
||||
}
|
||||
|
||||
# Check for default export
|
||||
if re.search(r'export\s+default\s+' + component_name, content):
|
||||
result['is_default_export'] = True
|
||||
|
||||
# Extract TypeScript interface/type props
|
||||
if result['has_typescript']:
|
||||
# Match interface or type definition
|
||||
interface_pattern = r'(?:interface|type)\s+' + component_name + r'Props\s*{([^}]+)}'
|
||||
match = re.search(interface_pattern, content, re.DOTALL)
|
||||
|
||||
if match:
|
||||
props_block = match.group(1)
|
||||
# Parse each prop
|
||||
prop_pattern = r'(\w+)(\?)?:\s*([^;]+);?'
|
||||
for prop_match in re.finditer(prop_pattern, props_block):
|
||||
prop_name = prop_match.group(1)
|
||||
is_optional = prop_match.group(2) == '?'
|
||||
prop_type = prop_match.group(3).strip()
|
||||
|
||||
# Determine control type based on prop type
|
||||
control = infer_control_type(prop_type)
|
||||
|
||||
# Extract possible values for enums
|
||||
values = extract_enum_values(prop_type)
|
||||
|
||||
result['props'].append({
|
||||
'name': prop_name,
|
||||
'type': prop_type,
|
||||
'optional': is_optional,
|
||||
'control': control,
|
||||
'values': values,
|
||||
'default': infer_default_value(prop_type, prop_name)
|
||||
})
|
||||
|
||||
# Fallback: extract props from function signature
|
||||
if not result['props']:
|
||||
func_pattern = r'(?:function|const)\s+' + component_name + r'\s*(?:<[^>]+>)?\s*\(\s*{\s*([^}]+)\s*}'
|
||||
match = re.search(func_pattern, content)
|
||||
|
||||
if match:
|
||||
props_str = match.group(1)
|
||||
# Simple extraction of prop names
|
||||
prop_names = [p.strip().split(':')[0].strip() for p in props_str.split(',')]
|
||||
|
||||
for prop_name in prop_names:
|
||||
result['props'].append({
|
||||
'name': prop_name,
|
||||
'type': 'any',
|
||||
'optional': False,
|
||||
'control': 'text',
|
||||
'values': None,
|
||||
'default': None
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def infer_control_type(prop_type: str) -> str:
|
||||
"""
|
||||
Infer Storybook control type from TypeScript type.
|
||||
|
||||
Args:
|
||||
prop_type: TypeScript type string
|
||||
|
||||
Returns:
|
||||
Storybook control type
|
||||
"""
|
||||
prop_type_lower = prop_type.lower()
|
||||
|
||||
# Boolean
|
||||
if 'boolean' in prop_type_lower:
|
||||
return 'boolean'
|
||||
|
||||
# Number
|
||||
if 'number' in prop_type_lower:
|
||||
return 'number'
|
||||
|
||||
# Union types (enums)
|
||||
if '|' in prop_type:
|
||||
return 'select'
|
||||
|
||||
# Objects
|
||||
if prop_type_lower in ['object', 'record']:
|
||||
return 'object'
|
||||
|
||||
# Arrays
|
||||
if '[]' in prop_type or prop_type.startswith('array'):
|
||||
return 'object'
|
||||
|
||||
# Functions
|
||||
if '=>' in prop_type or prop_type.startswith('('):
|
||||
return 'function'
|
||||
|
||||
# Default to text
|
||||
return 'text'
|
||||
|
||||
|
||||
def extract_enum_values(prop_type: str) -> Optional[List[str]]:
|
||||
"""
|
||||
Extract possible values from union type.
|
||||
|
||||
Args:
|
||||
prop_type: TypeScript type string (e.g., "'sm' | 'md' | 'lg'")
|
||||
|
||||
Returns:
|
||||
List of possible values or None
|
||||
"""
|
||||
if '|' not in prop_type:
|
||||
return None
|
||||
|
||||
# Extract string literals
|
||||
values = re.findall(r"['\"]([^'\"]+)['\"]", prop_type)
|
||||
|
||||
return values if values else None
|
||||
|
||||
|
||||
def infer_default_value(prop_type: str, prop_name: str) -> any:
|
||||
"""
|
||||
Infer reasonable default value for prop.
|
||||
|
||||
Args:
|
||||
prop_type: TypeScript type string
|
||||
prop_name: Prop name
|
||||
|
||||
Returns:
|
||||
Default value
|
||||
"""
|
||||
prop_type_lower = prop_type.lower()
|
||||
prop_name_lower = prop_name.lower()
|
||||
|
||||
# Boolean
|
||||
if 'boolean' in prop_type_lower:
|
||||
return False
|
||||
|
||||
# Number
|
||||
if 'number' in prop_type_lower:
|
||||
if 'count' in prop_name_lower:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
# Union types - return first value
|
||||
values = extract_enum_values(prop_type)
|
||||
if values:
|
||||
return values[0]
|
||||
|
||||
# Strings - context-aware defaults
|
||||
if 'name' in prop_name_lower:
|
||||
return 'John Doe'
|
||||
if 'title' in prop_name_lower:
|
||||
return 'Example Title'
|
||||
if 'description' in prop_name_lower or 'bio' in prop_name_lower:
|
||||
return 'This is an example description'
|
||||
if 'email' in prop_name_lower:
|
||||
return 'user@example.com'
|
||||
if 'url' in prop_name_lower or 'href' in prop_name_lower:
|
||||
return 'https://example.com'
|
||||
if 'image' in prop_name_lower or 'avatar' in prop_name_lower:
|
||||
return 'https://via.placeholder.com/150'
|
||||
|
||||
return 'Example text'
|
||||
|
||||
|
||||
def generate_variants(component_info: Dict) -> List[Dict]:
|
||||
"""
|
||||
Generate story variants based on component props.
|
||||
|
||||
Args:
|
||||
component_info: Component analysis result
|
||||
|
||||
Returns:
|
||||
List of variant definitions
|
||||
"""
|
||||
variants = []
|
||||
|
||||
# Generate variants for enum props
|
||||
for prop in component_info['props']:
|
||||
if prop['values'] and len(prop['values']) > 1:
|
||||
# Create variant for each enum value
|
||||
for value in prop['values']:
|
||||
if value != prop['default']: # Skip default (already in Default story)
|
||||
variant_name = value.capitalize()
|
||||
variants.append({
|
||||
'name': variant_name,
|
||||
'prop_name': prop['name'],
|
||||
'value': value
|
||||
})
|
||||
|
||||
# Generate boolean state variants
|
||||
for prop in component_info['props']:
|
||||
if prop['type'].lower() == 'boolean' and not prop['default']:
|
||||
variant_name = prop['name'].capitalize()
|
||||
variants.append({
|
||||
'name': variant_name,
|
||||
'prop_name': prop['name'],
|
||||
'value': True
|
||||
})
|
||||
|
||||
return variants
|
||||
|
||||
|
||||
def generate_story_content(component_info: Dict, framework: str = 'react') -> str:
|
||||
"""
|
||||
Generate complete Storybook story file content.
|
||||
|
||||
Args:
|
||||
component_info: Component analysis result
|
||||
framework: Framework name ('react', 'vue', 'svelte')
|
||||
|
||||
Returns:
|
||||
Story file content as string
|
||||
"""
|
||||
if framework == 'react':
|
||||
return generate_react_story(component_info)
|
||||
elif framework == 'vue':
|
||||
return generate_vue_story(component_info)
|
||||
elif framework == 'svelte':
|
||||
return generate_svelte_story(component_info)
|
||||
else:
|
||||
raise ValueError(f"Unsupported framework: {framework}")
|
||||
|
||||
|
||||
def generate_react_story(component_info: Dict) -> str:
|
||||
"""Generate React/TypeScript story."""
|
||||
name = component_info['name']
|
||||
props = component_info['props']
|
||||
variants = generate_variants(component_info)
|
||||
|
||||
# Build imports
|
||||
imports = f"""import type {{ Meta, StoryObj }} from '@storybook/react';
|
||||
import {{ {name} }} from './{name}';
|
||||
"""
|
||||
|
||||
# Build argTypes
|
||||
arg_types = []
|
||||
for prop in props:
|
||||
if prop['values']:
|
||||
arg_types.append(f" {prop['name']}: {{ control: '{prop['control']}', options: {json.dumps(prop['values'])} }}")
|
||||
else:
|
||||
arg_types.append(f" {prop['name']}: {{ control: '{prop['control']}' }}")
|
||||
|
||||
arg_types_str = ',\n'.join(arg_types) if arg_types else ''
|
||||
|
||||
# Build default args
|
||||
default_args = []
|
||||
for prop in props:
|
||||
if prop['default'] is not None:
|
||||
if isinstance(prop['default'], str):
|
||||
default_args.append(f" {prop['name']}: '{prop['default']}'")
|
||||
else:
|
||||
default_args.append(f" {prop['name']}: {json.dumps(prop['default'])}")
|
||||
|
||||
default_args_str = ',\n'.join(default_args) if default_args else ''
|
||||
|
||||
# Build meta
|
||||
meta = f"""
|
||||
const meta = {{
|
||||
title: '{component_info['story_title']}',
|
||||
component: {name},
|
||||
parameters: {{
|
||||
layout: 'centered',
|
||||
}},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {{
|
||||
{arg_types_str}
|
||||
}},
|
||||
}} satisfies Meta<typeof {name}>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
"""
|
||||
|
||||
# Default story
|
||||
default_story = f"""
|
||||
export const Default: Story = {{
|
||||
args: {{
|
||||
{default_args_str}
|
||||
}},
|
||||
}};
|
||||
"""
|
||||
|
||||
# Variant stories
|
||||
variant_stories = []
|
||||
for variant in variants:
|
||||
if isinstance(variant['value'], str):
|
||||
value_str = f"'{variant['value']}'"
|
||||
else:
|
||||
value_str = json.dumps(variant['value'])
|
||||
|
||||
variant_stories.append(f"""
|
||||
export const {variant['name']}: Story = {{
|
||||
args: {{
|
||||
...Default.args,
|
||||
{variant['prop_name']}: {value_str},
|
||||
}},
|
||||
}};
|
||||
""")
|
||||
|
||||
variant_stories_str = ''.join(variant_stories)
|
||||
|
||||
# Accessibility tests
|
||||
a11y = f"""
|
||||
// Accessibility tests
|
||||
Default.parameters = {{
|
||||
a11y: {{
|
||||
config: {{
|
||||
rules: [
|
||||
{{ id: 'color-contrast', enabled: true }},
|
||||
{{ id: 'label', enabled: true }},
|
||||
],
|
||||
}},
|
||||
}},
|
||||
}};
|
||||
"""
|
||||
|
||||
return imports + meta + default_story + variant_stories_str + a11y
|
||||
|
||||
|
||||
def generate_vue_story(component_info: Dict) -> str:
|
||||
"""Generate Vue story (simplified)."""
|
||||
name = component_info['name']
|
||||
|
||||
return f"""import type {{ Meta, StoryObj }} from '@storybook/vue3';
|
||||
import {name} from './{name}.vue';
|
||||
|
||||
const meta = {{
|
||||
title: 'Components/{name}',
|
||||
component: {name},
|
||||
tags: ['autodocs'],
|
||||
}} satisfies Meta<typeof {name}>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {{
|
||||
args: {{}},
|
||||
}};
|
||||
"""
|
||||
|
||||
|
||||
def generate_svelte_story(component_info: Dict) -> str:
|
||||
"""Generate Svelte story (simplified)."""
|
||||
name = component_info['name']
|
||||
|
||||
return f"""import type {{ Meta, StoryObj }} from '@storybook/svelte';
|
||||
import {name} from './{name}.svelte';
|
||||
|
||||
const meta = {{
|
||||
title: 'Components/{name}',
|
||||
component: {name},
|
||||
tags: ['autodocs'],
|
||||
}} satisfies Meta<typeof {name}>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {{
|
||||
args: {{}},
|
||||
}};
|
||||
"""
|
||||
|
||||
|
||||
def write_story_file(component_path: str, story_content: str) -> str:
|
||||
"""
|
||||
Write story file next to component file.
|
||||
|
||||
Args:
|
||||
component_path: Path to component file
|
||||
story_content: Generated story content
|
||||
|
||||
Returns:
|
||||
Path to created story file
|
||||
"""
|
||||
component_file = Path(component_path)
|
||||
story_file = component_file.parent / f"{component_file.stem}.stories{component_file.suffix}"
|
||||
|
||||
with open(story_file, 'w') as f:
|
||||
f.write(story_content)
|
||||
|
||||
return str(story_file)
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python story_generator.py <component_path> <framework>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
component_path = sys.argv[1]
|
||||
framework = sys.argv[2].lower()
|
||||
|
||||
if framework not in ['react', 'vue', 'svelte']:
|
||||
print(f"Unsupported framework: {framework}. Use: react, vue, or svelte", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(component_path):
|
||||
print(f"Component file not found: {component_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Analyze component
|
||||
if framework == 'react':
|
||||
component_info = analyze_react_component(component_path)
|
||||
else:
|
||||
# Simplified for Vue/Svelte
|
||||
component_info = {
|
||||
'name': extract_component_name(component_path),
|
||||
'path': component_path,
|
||||
'props': [],
|
||||
'story_title': f'Components/{extract_component_name(component_path)}'
|
||||
}
|
||||
|
||||
# Generate story
|
||||
story_content = generate_story_content(component_info, framework)
|
||||
|
||||
# Write story file
|
||||
story_file_path = write_story_file(component_path, story_content)
|
||||
|
||||
# Output result
|
||||
result = {
|
||||
'component': component_info,
|
||||
'story_file': story_file_path,
|
||||
'success': True
|
||||
}
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user