""" Storybook Scanner Discovers and analyzes existing Storybook stories in a project. """ import re import json from pathlib import Path from typing import List, Dict, Any, Optional, Set from dataclasses import dataclass, field @dataclass class StoryInfo: """Information about a Storybook story.""" name: str # Story name (e.g., "Primary") title: str # Story title (e.g., "Components/Button") component: str # Component name file_path: str # Path to story file args: Dict[str, Any] = field(default_factory=dict) # Default args parameters: Dict[str, Any] = field(default_factory=dict) decorators: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return { "name": self.name, "title": self.title, "component": self.component, "file_path": self.file_path, "args": self.args, "parameters": self.parameters, "decorators": self.decorators, "tags": self.tags, } @dataclass class StorybookConfig: """Storybook configuration details.""" version: str = "" framework: str = "" # react, vue, angular, etc. builder: str = "" # vite, webpack5, etc. addons: List[str] = field(default_factory=list) stories_patterns: List[str] = field(default_factory=list) static_dirs: List[str] = field(default_factory=list) config_path: str = "" def to_dict(self) -> Dict[str, Any]: return { "version": self.version, "framework": self.framework, "builder": self.builder, "addons": self.addons, "stories_patterns": self.stories_patterns, "static_dirs": self.static_dirs, "config_path": self.config_path, } class StorybookScanner: """ Scans a project for Storybook configuration and stories. """ # Common story file patterns STORY_PATTERNS = [ '*.stories.tsx', '*.stories.ts', '*.stories.jsx', '*.stories.js', '*.stories.mdx', ] def __init__(self, root_path: str): self.root = Path(root_path).resolve() async def scan(self) -> Dict[str, Any]: """ Perform full Storybook scan. Returns: Dict with configuration and story inventory """ config = await self._find_config() stories = await self._find_stories() # Group stories by component by_component: Dict[str, List[StoryInfo]] = {} for story in stories: if story.component not in by_component: by_component[story.component] = [] by_component[story.component].append(story) return { "config": config.to_dict() if config else None, "stories_count": len(stories), "components_with_stories": len(by_component), "stories": [s.to_dict() for s in stories], "by_component": { comp: [s.to_dict() for s in stories_list] for comp, stories_list in by_component.items() }, } async def _find_config(self) -> Optional[StorybookConfig]: """Find and parse Storybook configuration.""" # Look for .storybook directory storybook_dir = self.root / ".storybook" if not storybook_dir.exists(): # Try alternative locations for alt in ["storybook", ".storybook"]: alt_path = self.root / alt if alt_path.exists(): storybook_dir = alt_path break else: return None config = StorybookConfig(config_path=str(storybook_dir)) # Parse main.js/ts for main_file in ["main.ts", "main.js", "main.mjs"]: main_path = storybook_dir / main_file if main_path.exists(): await self._parse_main_config(main_path, config) break # Check package.json for Storybook version pkg_json = self.root / "package.json" if pkg_json.exists(): try: pkg = json.loads(pkg_json.read_text()) deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} # Get Storybook version for pkg_name in ["@storybook/react", "@storybook/vue3", "@storybook/angular"]: if pkg_name in deps: config.version = deps[pkg_name].lstrip("^~") config.framework = pkg_name.split("/")[1] break # Get builder if "@storybook/builder-vite" in deps: config.builder = "vite" elif "@storybook/builder-webpack5" in deps: config.builder = "webpack5" # Get addons config.addons = [ pkg for pkg in deps.keys() if pkg.startswith("@storybook/addon-") ] except (json.JSONDecodeError, KeyError): pass return config async def _parse_main_config(self, main_path: Path, config: StorybookConfig) -> None: """Parse main.js/ts for configuration.""" try: content = main_path.read_text(encoding="utf-8") # Extract stories patterns stories_match = re.search( r'stories\s*:\s*\[([^\]]+)\]', content, re.DOTALL ) if stories_match: patterns_str = stories_match.group(1) patterns = re.findall(r'["\']([^"\']+)["\']', patterns_str) config.stories_patterns = patterns # Extract static dirs static_match = re.search( r'staticDirs\s*:\s*\[([^\]]+)\]', content, re.DOTALL ) if static_match: dirs_str = static_match.group(1) dirs = re.findall(r'["\']([^"\']+)["\']', dirs_str) config.static_dirs = dirs # Extract framework framework_match = re.search( r'framework\s*:\s*["\'](@storybook/[^"\']+)["\']', content ) if framework_match: config.framework = framework_match.group(1) except Exception: pass async def _find_stories(self) -> List[StoryInfo]: """Find all story files in the project.""" stories = [] skip_dirs = {'node_modules', '.git', 'dist', 'build'} for pattern in self.STORY_PATTERNS: for story_path in self.root.rglob(pattern): if any(skip in story_path.parts for skip in skip_dirs): continue try: file_stories = await self._parse_story_file(story_path) stories.extend(file_stories) except Exception: continue return stories async def _parse_story_file(self, story_path: Path) -> List[StoryInfo]: """Parse a story file to extract story information.""" content = story_path.read_text(encoding="utf-8", errors="ignore") rel_path = str(story_path.relative_to(self.root)) stories = [] # Extract meta/default export title = "" component = "" # CSF3 format: const meta = { title: '...', component: ... } meta_match = re.search( r'(?:const\s+meta|export\s+default)\s*[=:]\s*\{([^}]+)\}', content, re.DOTALL ) if meta_match: meta_content = meta_match.group(1) title_match = re.search(r'title\s*:\s*["\']([^"\']+)["\']', meta_content) if title_match: title = title_match.group(1) comp_match = re.search(r'component\s*:\s*(\w+)', meta_content) if comp_match: component = comp_match.group(1) # If no title, derive from file path if not title: # Convert path to title (e.g., src/components/Button.stories.tsx -> Components/Button) parts = story_path.stem.replace('.stories', '').split('/') title = '/'.join(p.title() for p in parts[-2:] if p) if not component: component = story_path.stem.replace('.stories', '') # Find exported stories (CSF3 format) # export const Primary: Story = { ... } story_pattern = re.compile( r'export\s+const\s+(\w+)\s*(?::\s*\w+)?\s*=\s*\{([^}]*)\}', re.DOTALL ) for match in story_pattern.finditer(content): story_name = match.group(1) story_content = match.group(2) # Skip meta export if story_name.lower() in ['meta', 'default']: continue # Parse args args = {} args_match = re.search(r'args\s*:\s*\{([^}]*)\}', story_content) if args_match: args_str = args_match.group(1) # Simple key-value extraction for kv_match in re.finditer(r'(\w+)\s*:\s*["\']?([^,\n"\']+)["\']?', args_str): args[kv_match.group(1)] = kv_match.group(2).strip() stories.append(StoryInfo( name=story_name, title=title, component=component, file_path=rel_path, args=args, )) # Also check for older CSF2 format # export const Primary = Template.bind({}) csf2_pattern = re.compile( r'export\s+const\s+(\w+)\s*=\s*Template\.bind\(\{\}\)' ) for match in csf2_pattern.finditer(content): story_name = match.group(1) if not any(s.name == story_name for s in stories): stories.append(StoryInfo( name=story_name, title=title, component=component, file_path=rel_path, )) return stories async def get_components_without_stories( self, component_files: List[str] ) -> List[str]: """ Find components that don't have Storybook stories. Args: component_files: List of component file paths Returns: List of component paths without stories """ # Get all components with stories result = await self.scan() components_with_stories = set(result.get("by_component", {}).keys()) # Find components without stories without_stories = [] for comp_path in component_files: # Extract component name from path comp_name = Path(comp_path).stem if comp_name not in components_with_stories: without_stories.append(comp_path) return without_stories async def get_story_coverage(self) -> Dict[str, Any]: """ Calculate story coverage statistics. Returns: Coverage statistics including counts and percentages """ result = await self.scan() stories_count = result.get("stories_count", 0) components_count = result.get("components_with_stories", 0) # Count stories per component by_component = result.get("by_component", {}) stories_per_component = { comp: len(stories) for comp, stories in by_component.items() } avg_stories = ( sum(stories_per_component.values()) / len(stories_per_component) if stories_per_component else 0 ) return { "total_stories": stories_count, "components_covered": components_count, "average_stories_per_component": round(avg_stories, 1), "stories_per_component": stories_per_component, }