generate-component.ts
1 #!/usr/bin/env tsx 2 /** 3 * Component Generator 4 * Creates new components with consistent structure and patterns 5 * 6 * Usage: 7 * yarn generate ComponentName 8 * yarn generate ComponentName --variant # Creates CVA variant component 9 * yarn generate ComponentName --compound # Creates compound component (with subcomponents) 10 */ 11 12 import { mkdirSync, writeFileSync, existsSync } from 'fs'; 13 import { join } from 'path'; 14 15 const args = process.argv.slice(2); 16 const componentName = args[0]; 17 const hasVariants = args.includes('--variant') || args.includes('-v'); 18 const isCompound = args.includes('--compound') || args.includes('-c'); 19 20 if (!componentName) { 21 console.error('Usage: yarn generate ComponentName [--variant] [--compound]'); 22 process.exit(1); 23 } 24 25 // Validate component name (PascalCase) 26 if (!/^[A-Z][a-zA-Z0-9]*$/.test(componentName)) { 27 console.error('Component name must be PascalCase (e.g., MyComponent)'); 28 process.exit(1); 29 } 30 31 const componentsDir = join(process.cwd(), 'src', 'components'); 32 const componentDir = join(componentsDir, componentName); 33 const storiesDir = join(process.cwd(), 'stories'); 34 35 // Check if component already exists 36 if (existsSync(componentDir)) { 37 console.error(`Component ${componentName} already exists at ${componentDir}`); 38 process.exit(1); 39 } 40 41 // Create component directory 42 mkdirSync(componentDir, { recursive: true }); 43 44 // Generate component file 45 const componentTemplate = hasVariants 46 ? generateVariantComponent(componentName) 47 : isCompound 48 ? generateCompoundComponent(componentName) 49 : generateBasicComponent(componentName); 50 51 // Generate test file 52 const testTemplate = generateTest(componentName); 53 54 // Generate story file 55 const storyTemplate = generateStory(componentName, hasVariants); 56 57 // Generate index file 58 const indexTemplate = `export * from './${componentName}';\n`; 59 60 // Write files 61 writeFileSync(join(componentDir, `${componentName}.tsx`), componentTemplate); 62 writeFileSync(join(componentDir, `${componentName}.test.tsx`), testTemplate); 63 writeFileSync(join(componentDir, 'index.ts'), indexTemplate); 64 writeFileSync(join(storiesDir, `${componentName}.stories.tsx`), storyTemplate); 65 66 console.log(`✓ Created ${componentName} component`); 67 console.log(` - src/components/${componentName}/${componentName}.tsx`); 68 console.log(` - src/components/${componentName}/${componentName}.test.tsx`); 69 console.log(` - src/components/${componentName}/index.ts`); 70 console.log(` - stories/${componentName}.stories.tsx`); 71 console.log(''); 72 console.log('Next steps:'); 73 console.log(` 1. Export from src/index.ts: export * from './components/${componentName}';`); 74 console.log(' 2. Implement component logic'); 75 console.log(' 3. Add stories for all variants'); 76 console.log(' 4. Run: yarn storybook'); 77 78 // ============ Template Generators ============ 79 80 function generateBasicComponent(name: string): string { 81 return `import * as React from 'react'; 82 import { cn } from '../../lib/utils'; 83 84 export interface ${name}Props extends React.HTMLAttributes<HTMLDivElement> { 85 /** Component children */ 86 children?: React.ReactNode; 87 } 88 89 /** 90 * ${name} - Description here 91 */ 92 const ${name} = React.forwardRef<HTMLDivElement, ${name}Props>( 93 ({ className, children, ...props }, ref) => { 94 return ( 95 <div 96 ref={ref} 97 className={cn( 98 // Base styles 99 'bg-[var(--bg-primary)]', 100 'text-[var(--text-primary)]', 101 className 102 )} 103 {...props} 104 > 105 {children} 106 </div> 107 ); 108 } 109 ); 110 111 ${name}.displayName = '${name}'; 112 113 export { ${name} }; 114 `; 115 } 116 117 function generateVariantComponent(name: string): string { 118 const variantName = name.charAt(0).toLowerCase() + name.slice(1); 119 return `import * as React from 'react'; 120 import { cva, type VariantProps } from 'class-variance-authority'; 121 import { cn } from '../../lib/utils'; 122 123 /** 124 * ${name} variants using ACDC design system tokens 125 */ 126 const ${variantName}Variants = cva( 127 // Base styles 128 [ 129 'inline-flex items-center justify-center', 130 'transition-all duration-150 ease-out', 131 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', 132 ], 133 { 134 variants: { 135 variant: { 136 default: [ 137 'bg-[var(--bg-tertiary)]', 138 'text-[var(--text-primary)]', 139 'border border-[var(--border-default)]', 140 ], 141 alpha: [ 142 'bg-[var(--alpha-500)]', 143 'text-white', 144 'hover:bg-[var(--alpha-600)]', 145 ], 146 delta: [ 147 'bg-[var(--delta-500)]', 148 'text-white', 149 'hover:bg-[var(--delta-600)]', 150 ], 151 }, 152 size: { 153 sm: 'h-8 px-3 text-sm', 154 md: 'h-10 px-4 text-base', 155 lg: 'h-12 px-6 text-lg', 156 }, 157 }, 158 defaultVariants: { 159 variant: 'default', 160 size: 'md', 161 }, 162 } 163 ); 164 165 export interface ${name}Props 166 extends React.HTMLAttributes<HTMLDivElement>, 167 VariantProps<typeof ${variantName}Variants> {} 168 169 /** 170 * ${name} - Description here 171 */ 172 const ${name} = React.forwardRef<HTMLDivElement, ${name}Props>( 173 ({ className, variant, size, ...props }, ref) => { 174 return ( 175 <div 176 ref={ref} 177 className={cn(${variantName}Variants({ variant, size, className }))} 178 {...props} 179 /> 180 ); 181 } 182 ); 183 184 ${name}.displayName = '${name}'; 185 186 export { ${name}, ${variantName}Variants }; 187 `; 188 } 189 190 function generateCompoundComponent(name: string): string { 191 return `import * as React from 'react'; 192 import { cn } from '../../lib/utils'; 193 194 // ============ Main Component ============ 195 196 export interface ${name}Props extends React.HTMLAttributes<HTMLDivElement> { 197 /** Component children */ 198 children?: React.ReactNode; 199 } 200 201 const ${name} = React.forwardRef<HTMLDivElement, ${name}Props>( 202 ({ className, children, ...props }, ref) => { 203 return ( 204 <div 205 ref={ref} 206 className={cn( 207 'bg-[var(--bg-elevated)]', 208 'border border-[var(--border-subtle)]', 209 'rounded-[var(--radius-md)]', 210 className 211 )} 212 {...props} 213 > 214 {children} 215 </div> 216 ); 217 } 218 ); 219 ${name}.displayName = '${name}'; 220 221 // ============ Sub Components ============ 222 223 const ${name}Header = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( 224 ({ className, ...props }, ref) => ( 225 <div 226 ref={ref} 227 className={cn( 228 'p-4 border-b border-[var(--border-subtle)]', 229 className 230 )} 231 {...props} 232 /> 233 ) 234 ); 235 ${name}Header.displayName = '${name}Header'; 236 237 const ${name}Title = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>( 238 ({ className, ...props }, ref) => ( 239 <h3 240 ref={ref} 241 className={cn( 242 'text-lg font-semibold text-[var(--text-primary)]', 243 className 244 )} 245 {...props} 246 /> 247 ) 248 ); 249 ${name}Title.displayName = '${name}Title'; 250 251 const ${name}Content = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( 252 ({ className, ...props }, ref) => ( 253 <div 254 ref={ref} 255 className={cn('p-4', className)} 256 {...props} 257 /> 258 ) 259 ); 260 ${name}Content.displayName = '${name}Content'; 261 262 const ${name}Footer = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( 263 ({ className, ...props }, ref) => ( 264 <div 265 ref={ref} 266 className={cn( 267 'p-4 border-t border-[var(--border-subtle)]', 268 'flex items-center justify-end gap-2', 269 className 270 )} 271 {...props} 272 /> 273 ) 274 ); 275 ${name}Footer.displayName = '${name}Footer'; 276 277 export { ${name}, ${name}Header, ${name}Title, ${name}Content, ${name}Footer }; 278 `; 279 } 280 281 function generateTest(name: string): string { 282 return `import { describe, it, expect } from 'vitest'; 283 import { render, screen } from '@testing-library/react'; 284 import { ${name} } from './${name}'; 285 286 describe('${name}', () => { 287 it('renders children', () => { 288 render(<${name}>Test content</${name}>); 289 expect(screen.getByText('Test content')).toBeInTheDocument(); 290 }); 291 292 it('applies custom className', () => { 293 render(<${name} className="custom-class">Content</${name}>); 294 expect(screen.getByText('Content')).toHaveClass('custom-class'); 295 }); 296 297 it('forwards ref', () => { 298 const ref = { current: null }; 299 render(<${name} ref={ref}>Content</${name}>); 300 expect(ref.current).toBeInstanceOf(HTMLDivElement); 301 }); 302 }); 303 `; 304 } 305 306 function generateStory(name: string, hasVariants: boolean): string { 307 if (hasVariants) { 308 return `import type { Meta, StoryObj } from '@storybook/react'; 309 import { ${name} } from '../src/components/${name}'; 310 311 const meta: Meta<typeof ${name}> = { 312 title: 'Components/${name}', 313 component: ${name}, 314 parameters: { 315 layout: 'centered', 316 }, 317 tags: ['autodocs'], 318 argTypes: { 319 variant: { 320 control: 'select', 321 options: ['default', 'alpha', 'delta'], 322 }, 323 size: { 324 control: 'select', 325 options: ['sm', 'md', 'lg'], 326 }, 327 }, 328 }; 329 330 export default meta; 331 type Story = StoryObj<typeof ${name}>; 332 333 export const Default: Story = { 334 args: { 335 children: '${name}', 336 }, 337 }; 338 339 export const Alpha: Story = { 340 args: { 341 children: 'Alpha', 342 variant: 'alpha', 343 }, 344 }; 345 346 export const Delta: Story = { 347 args: { 348 children: 'Delta', 349 variant: 'delta', 350 }, 351 }; 352 353 export const Small: Story = { 354 args: { 355 children: 'Small', 356 size: 'sm', 357 }, 358 }; 359 360 export const Large: Story = { 361 args: { 362 children: 'Large', 363 size: 'lg', 364 }, 365 }; 366 367 export const AllVariants: Story = { 368 render: () => ( 369 <div className="flex flex-wrap gap-4"> 370 <${name} variant="default">Default</${name}> 371 <${name} variant="alpha">Alpha</${name}> 372 <${name} variant="delta">Delta</${name}> 373 </div> 374 ), 375 }; 376 377 export const AllSizes: Story = { 378 render: () => ( 379 <div className="flex items-center gap-4"> 380 <${name} size="sm">Small</${name}> 381 <${name} size="md">Medium</${name}> 382 <${name} size="lg">Large</${name}> 383 </div> 384 ), 385 }; 386 `; 387 } 388 389 return `import type { Meta, StoryObj } from '@storybook/react'; 390 import { ${name} } from '../src/components/${name}'; 391 392 const meta: Meta<typeof ${name}> = { 393 title: 'Components/${name}', 394 component: ${name}, 395 parameters: { 396 layout: 'centered', 397 }, 398 tags: ['autodocs'], 399 }; 400 401 export default meta; 402 type Story = StoryObj<typeof ${name}>; 403 404 export const Default: Story = { 405 args: { 406 children: '${name} content', 407 }, 408 }; 409 410 export const WithCustomClass: Story = { 411 args: { 412 children: 'Custom styled', 413 className: 'p-4 bg-gray-100', 414 }, 415 }; 416 `; 417 }