playbook/antigravity-awesome-skills/skills/senior-frontend/scripts/component_generator.py

387 lines
10 KiB
Python

#!/usr/bin/env python3
"""
React Component Generator
Generates React/Next.js component files with TypeScript, Tailwind CSS,
and optional test files following best practices.
Usage:
python component_generator.py Button --dir src/components/ui
python component_generator.py ProductCard --type client --with-test
python component_generator.py UserProfile --type server --with-story
"""
import argparse
import os
import re
import sys
from pathlib import Path
from datetime import datetime
# Component templates
TEMPLATES = {
"client": '''\'use client\';
import {{ useState }} from 'react';
import {{ cn }} from '@/lib/utils';
interface {name}Props {{
className?: string;
children?: React.ReactNode;
}}
export function {name}({{ className, children }}: {name}Props) {{
return (
<div className={{cn('', className)}}>
{{children}}
</div>
);
}}
''',
"server": '''import {{ cn }} from '@/lib/utils';
interface {name}Props {{
className?: string;
children?: React.ReactNode;
}}
export async function {name}({{ className, children }}: {name}Props) {{
return (
<div className={{cn('', className)}}>
{{children}}
</div>
);
}}
''',
"hook": '''import {{ useState, useEffect, useCallback }} from 'react';
interface Use{name}Options {{
// Add options here
}}
interface Use{name}Return {{
// Add return type here
isLoading: boolean;
error: Error | null;
}}
export function use{name}(options: Use{name}Options = {{}}): Use{name}Return {{
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {{
// Effect logic here
}}, []);
return {{
isLoading,
error,
}};
}}
''',
"test": '''import {{ render, screen }} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {{ {name} }} from './{name}';
describe('{name}', () => {{
it('renders correctly', () => {{
render(<{name}>Test content</{name}>);
expect(screen.getByText('Test content')).toBeInTheDocument();
}});
it('applies custom className', () => {{
render(<{name} className="custom-class">Content</{name}>);
expect(screen.getByText('Content').parentElement).toHaveClass('custom-class');
}});
// Add more tests here
}});
''',
"story": '''import type {{ Meta, StoryObj }} from '@storybook/react';
import {{ {name} }} from './{name}';
const meta: Meta<typeof {name}> = {{
title: 'Components/{name}',
component: {name},
tags: ['autodocs'],
argTypes: {{
className: {{
control: 'text',
description: 'Additional CSS classes',
}},
}},
}};
export default meta;
type Story = StoryObj<typeof {name}>;
export const Default: Story = {{
args: {{
children: 'Default content',
}},
}};
export const WithCustomClass: Story = {{
args: {{
className: 'bg-blue-100 p-4',
children: 'Styled content',
}},
}};
''',
"index": '''export {{ {name} }} from './{name}';
export type {{ {name}Props }} from './{name}';
''',
}
_COMPONENT_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
def _safe_component_name(name: str) -> str:
name = name.strip()
if not _COMPONENT_NAME_RE.fullmatch(name):
raise ValueError("Component name must start with a letter and contain only letters, numbers, hyphens, or underscores")
return name
def _safe_component_dir(output_dir: Path, pascal_name: str, flat: bool) -> Path:
output_root = output_dir.resolve()
component_dir = output_root if flat else (output_root / pascal_name).resolve()
component_dir.relative_to(output_root)
return component_dir
def _safe_output_dir(raw_dir: str) -> Path:
raw = str(raw_dir).strip()
if "\x00" in raw:
raise ValueError("Output directory contains an invalid null byte")
parts = Path(raw).parts
if any(part == ".." for part in parts):
raise ValueError("Output directory must not contain '..' segments")
return Path(raw).expanduser().resolve()
def _safe_component_file(component_dir: Path, filename: str) -> Path:
if "/" in filename or "\\" in filename or "\x00" in filename or ".." in filename:
raise ValueError(f"Unsafe generated filename: {filename}")
target = (component_dir / filename).resolve()
target.relative_to(component_dir.resolve())
return target
def to_pascal_case(name: str) -> str:
"""Convert string to PascalCase."""
name = _safe_component_name(name)
# Handle kebab-case and snake_case
words = name.replace('-', '_').split('_')
return ''.join(word.capitalize() for word in words)
def to_kebab_case(name: str) -> str:
"""Convert PascalCase to kebab-case."""
result = []
for i, char in enumerate(name):
if char.isupper() and i > 0:
result.append('-')
result.append(char.lower())
return ''.join(result)
def generate_component(
name: str,
output_dir: Path,
component_type: str = "client",
with_test: bool = False,
with_story: bool = False,
with_index: bool = True,
flat: bool = False,
) -> dict:
"""Generate component files."""
pascal_name = to_pascal_case(name)
kebab_name = to_kebab_case(pascal_name)
# Determine output path
component_dir = _safe_component_dir(output_dir, pascal_name, flat)
files_created = []
# Create directory
component_dir.mkdir(parents=True, exist_ok=True)
# Generate main component file
if component_type == "hook":
main_file = _safe_component_file(component_dir, f"use{pascal_name}.ts")
template = TEMPLATES["hook"]
else:
main_file = _safe_component_file(component_dir, f"{pascal_name}.tsx")
template = TEMPLATES[component_type]
content = template.format(name=pascal_name)
main_file.write_text(content)
files_created.append(str(main_file))
# Generate test file
if with_test and component_type != "hook":
test_file = _safe_component_file(component_dir, f"{pascal_name}.test.tsx")
test_content = TEMPLATES["test"].format(name=pascal_name)
test_file.write_text(test_content)
files_created.append(str(test_file))
# Generate story file
if with_story and component_type != "hook":
story_file = _safe_component_file(component_dir, f"{pascal_name}.stories.tsx")
story_content = TEMPLATES["story"].format(name=pascal_name)
story_file.write_text(story_content)
files_created.append(str(story_file))
# Generate index file
if with_index and not flat:
index_file = _safe_component_file(component_dir, "index.ts")
index_content = TEMPLATES["index"].format(name=pascal_name)
index_file.write_text(index_content)
files_created.append(str(index_file))
return {
"name": pascal_name,
"type": component_type,
"directory": str(component_dir),
"files": files_created,
}
def print_result(result: dict, verbose: bool = False) -> None:
"""Print generation result."""
print(f"\n{'='*50}")
print(f"Component Generated: {result['name']}")
print(f"{'='*50}")
print(f"Type: {result['type']}")
print(f"Directory: {result['directory']}")
print(f"\nFiles created:")
for file in result['files']:
print(f" - {file}")
print(f"{'='*50}\n")
# Print usage hint
if result['type'] != 'hook':
print("Usage:")
print(f" import {{ {result['name']} }} from '@/components/{result['name']}';")
print(f"\n <{result['name']}>Content</{result['name']}>")
else:
print("Usage:")
print(f" import {{ use{result['name']} }} from '@/hooks/use{result['name']}';")
print(f"\n const {{ isLoading, error }} = use{result['name']}();")
def self_test() -> None:
assert to_pascal_case("product-card") == "ProductCard"
for bad_name in ("../Card", "Bad.Name", "", "1Card"):
try:
to_pascal_case(bad_name)
except ValueError:
pass
else:
raise AssertionError(f"accepted unsafe component name: {bad_name!r}")
def main():
parser = argparse.ArgumentParser(
description="Generate React/Next.js components with TypeScript and Tailwind CSS"
)
parser.add_argument(
"name",
nargs="?",
help="Component name (PascalCase or kebab-case)"
)
parser.add_argument(
"--self-test",
action="store_true",
help="Run safety self-checks"
)
parser.add_argument(
"--dir", "-d",
default="src/components",
help="Output directory (default: src/components)"
)
parser.add_argument(
"--type", "-t",
choices=["client", "server", "hook"],
default="client",
help="Component type (default: client)"
)
parser.add_argument(
"--with-test",
action="store_true",
help="Generate test file"
)
parser.add_argument(
"--with-story",
action="store_true",
help="Generate Storybook story file"
)
parser.add_argument(
"--no-index",
action="store_true",
help="Skip generating index.ts file"
)
parser.add_argument(
"--flat",
action="store_true",
help="Create files directly in output dir without subdirectory"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be generated without creating files"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose output"
)
args = parser.parse_args()
if args.self_test:
self_test()
return
if not args.name:
parser.error("name is required unless --self-test is used")
output_dir = _safe_output_dir(args.dir)
pascal_name = to_pascal_case(args.name)
if args.dry_run:
print(f"\nDry run - would generate:")
print(f" Component: {pascal_name}")
print(f" Type: {args.type}")
print(f" Directory: {output_dir / pascal_name if not args.flat else output_dir}")
print(f" Test: {'Yes' if args.with_test else 'No'}")
print(f" Story: {'Yes' if args.with_story else 'No'}")
return
try:
result = generate_component(
name=args.name,
output_dir=output_dir,
component_type=args.type,
with_test=args.with_test,
with_story=args.with_story,
with_index=not args.no_index,
flat=args.flat,
)
print_result(result, args.verbose)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()